Просмотр исходного кода

feat(ui): improve mobile responsive layouts

CaIon 1 неделя назад
Родитель
Сommit
d46df94f05
84 измененных файлов с 1172 добавлено и 729 удалено
  1. 4 4
      web/default/src/components/data-table/mobile-card-list.tsx
  2. 5 5
      web/default/src/components/data-table/pagination.tsx
  3. 5 5
      web/default/src/components/data-table/toolbar.tsx
  4. 2 2
      web/default/src/components/data-table/view-options.tsx
  5. 9 7
      web/default/src/components/layout/components/section-page-layout.tsx
  6. 81 0
      web/default/src/components/ui/titled-card.tsx
  7. 19 3
      web/default/src/features/auth/hooks/use-auth-redirect.ts
  8. 5 2
      web/default/src/features/channels/components/channels-table.tsx
  9. 5 5
      web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  10. 4 4
      web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx
  11. 7 4
      web/default/src/features/dashboard/components/models/log-stat-cards.tsx
  12. 4 4
      web/default/src/features/dashboard/components/models/model-charts.tsx
  13. 9 9
      web/default/src/features/dashboard/components/models/models-filter-dialog.tsx
  14. 4 4
      web/default/src/features/dashboard/components/overview/announcements-panel.tsx
  15. 4 4
      web/default/src/features/dashboard/components/overview/api-info-item.tsx
  16. 3 3
      web/default/src/features/dashboard/components/overview/api-info-panel.tsx
  17. 2 2
      web/default/src/features/dashboard/components/overview/faq-panel.tsx
  18. 4 9
      web/default/src/features/dashboard/components/overview/summary-cards.tsx
  19. 5 5
      web/default/src/features/dashboard/components/overview/uptime-panel.tsx
  20. 6 6
      web/default/src/features/dashboard/components/ui/panel-wrapper.tsx
  21. 12 10
      web/default/src/features/dashboard/components/ui/stat-card.tsx
  22. 11 12
      web/default/src/features/dashboard/components/users/user-charts.tsx
  23. 2 6
      web/default/src/features/dashboard/hooks/use-dashboard-config.tsx
  24. 4 4
      web/default/src/features/dashboard/index.tsx
  25. 2 2
      web/default/src/features/dashboard/lib/filters.ts
  26. 13 5
      web/default/src/features/keys/components/api-key-group-combobox.tsx
  27. 32 25
      web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
  28. 138 13
      web/default/src/features/keys/components/api-keys-table.tsx
  29. 2 2
      web/default/src/features/models/components/deployments-table.tsx
  30. 7 7
      web/default/src/features/models/components/dialogs/update-config-dialog.tsx
  31. 5 5
      web/default/src/features/models/components/dialogs/view-details-dialog.tsx
  32. 6 6
      web/default/src/features/models/components/dialogs/view-logs-dialog.tsx
  33. 4 4
      web/default/src/features/models/components/drawers/model-mutate-drawer.tsx
  34. 5 5
      web/default/src/features/models/components/drawers/prefill-group-form-drawer.tsx
  35. 5 2
      web/default/src/features/models/components/models-table.tsx
  36. 64 4
      web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx
  37. 6 6
      web/default/src/features/pricing/components/filter-bar.tsx
  38. 3 3
      web/default/src/features/pricing/components/model-card.tsx
  39. 2 2
      web/default/src/features/pricing/components/model-details.tsx
  40. 3 3
      web/default/src/features/pricing/components/pricing-toolbar.tsx
  41. 5 5
      web/default/src/features/pricing/index.tsx
  42. 8 0
      web/default/src/features/profile/api.ts
  43. 136 0
      web/default/src/features/profile/components/language-preferences-card.tsx
  44. 86 74
      web/default/src/features/profile/components/passkey-card.tsx
  45. 16 16
      web/default/src/features/profile/components/profile-header.tsx
  46. 18 31
      web/default/src/features/profile/components/profile-security-card.tsx
  47. 14 29
      web/default/src/features/profile/components/profile-settings-card.tsx
  48. 6 6
      web/default/src/features/profile/components/sidebar-modules-card.tsx
  49. 11 11
      web/default/src/features/profile/components/tabs/account-bindings-tab.tsx
  50. 31 23
      web/default/src/features/profile/components/tabs/notification-tab.tsx
  51. 8 8
      web/default/src/features/profile/components/two-fa-card.tsx
  52. 10 5
      web/default/src/features/profile/index.tsx
  53. 2 0
      web/default/src/features/profile/types.ts
  54. 5 5
      web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx
  55. 2 2
      web/default/src/features/redemption-codes/components/redemptions-table.tsx
  56. 5 5
      web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx
  57. 9 9
      web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx
  58. 1 1
      web/default/src/features/subscriptions/components/subscriptions-table.tsx
  59. 1 1
      web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx
  60. 7 7
      web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx
  61. 22 19
      web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx
  62. 6 6
      web/default/src/features/usage-logs/components/dialogs/usage-logs-filter-dialog.tsx
  63. 3 3
      web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx
  64. 4 4
      web/default/src/features/usage-logs/components/usage-logs-table.tsx
  65. 4 4
      web/default/src/features/users/components/users-mutate-drawer.tsx
  66. 2 2
      web/default/src/features/users/components/users-table.tsx
  67. 57 108
      web/default/src/features/wallet/components/affiliate-rewards-card.tsx
  68. 3 3
      web/default/src/features/wallet/components/creem-products-section.tsx
  69. 13 13
      web/default/src/features/wallet/components/dialogs/billing-history-dialog.tsx
  70. 3 3
      web/default/src/features/wallet/components/dialogs/creem-confirm-dialog.tsx
  71. 3 3
      web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx
  72. 3 3
      web/default/src/features/wallet/components/dialogs/transfer-dialog.tsx
  73. 40 53
      web/default/src/features/wallet/components/recharge-form-card.tsx
  74. 31 35
      web/default/src/features/wallet/components/subscription-plans-card.tsx
  75. 5 5
      web/default/src/features/wallet/components/wallet-stats-card.tsx
  76. 28 13
      web/default/src/features/wallet/index.tsx
  77. 6 0
      web/default/src/i18n/locales/en.json
  78. 6 0
      web/default/src/i18n/locales/fr.json
  79. 6 0
      web/default/src/i18n/locales/ja.json
  80. 6 0
      web/default/src/i18n/locales/ru.json
  81. 6 0
      web/default/src/i18n/locales/vi.json
  82. 6 0
      web/default/src/i18n/locales/zh.json
  83. 14 0
      web/default/src/lib/time.ts
  84. 1 1
      web/default/src/stores/auth-store.ts

+ 4 - 4
web/default/src/components/data-table/mobile-card-list.tsx

@@ -62,7 +62,7 @@ function ListSkeleton() {
             <Skeleton className='h-4 w-32' />
             <Skeleton className='h-5 w-16 rounded-full' />
           </div>
-          <div className='mt-1.5 flex items-start gap-4'>
+          <div className='mt-1.5 grid grid-cols-2 gap-2'>
             <div className='flex-1'>
               <Skeleton className='mb-1 h-2 w-8' />
               <Skeleton className='h-4 w-full' />
@@ -136,9 +136,9 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
         )}
       </div>
 
-      {/* Row 2: Key fields side by side */}
+      {/* Row 2: Key fields wrap into compact columns instead of squeezing */}
       {fieldCells.length > 0 && (
-        <div className='mt-1.5 flex items-start gap-4'>
+        <div className='mt-1.5 grid grid-cols-2 gap-x-3 gap-y-1.5'>
           {fieldCells.map((cell) => {
             const label = getCellLabel(cell)
             return (
@@ -260,7 +260,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
 
   if (!rows || rows.length === 0) {
     return (
-      <div className='rounded-lg border p-8'>
+      <div className='rounded-lg border p-6'>
         <Empty className='border-none p-0'>
           <EmptyHeader>
             <EmptyMedia variant='icon'>

+ 5 - 5
web/default/src/components/data-table/pagination.tsx

@@ -32,12 +32,12 @@ export function DataTablePagination<TData>({
     <div
       className={cn(
         'flex items-center justify-between overflow-clip',
-        '@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4'
+        '@max-2xl/content:flex-col-reverse @max-2xl/content:gap-2 sm:@max-2xl/content:gap-4'
       )}
       style={{ overflowClipMargin: 1 }}
     >
-      <div className='flex w-full items-center justify-between'>
-        <div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @2xl/content:hidden'>
+      <div className='flex w-full items-center justify-between gap-2'>
+        <div className='flex min-w-0 items-center text-xs font-medium whitespace-nowrap sm:min-w-[130px] sm:text-sm @2xl/content:hidden'>
           {t('Page {{current}} of {{total}}', {
             current: currentPage,
             total: totalPages,
@@ -50,7 +50,7 @@ export function DataTablePagination<TData>({
               table.setPageSize(Number(value))
             }}
           >
-            <SelectTrigger className='h-8 w-[70px]'>
+            <SelectTrigger className='h-8 w-[64px] sm:w-[70px]'>
               <SelectValue placeholder={table.getState().pagination.pageSize} />
             </SelectTrigger>
             <SelectContent side='top'>
@@ -74,7 +74,7 @@ export function DataTablePagination<TData>({
             total: totalPages,
           })}
         </div>
-        <div className='flex items-center space-x-2'>
+        <div className='flex items-center space-x-1.5 sm:space-x-2'>
           <Button
             variant='outline'
             className='size-8 p-0 @max-md/content:hidden'

+ 5 - 5
web/default/src/components/data-table/toolbar.tsx

@@ -64,14 +64,14 @@ export function DataTableToolbar<TData>({
       onChange={(event) =>
         table.getColumn(searchKey)?.setFilterValue(event.target.value)
       }
-      className='h-8 w-full sm:w-[150px] lg:w-[250px]'
+      className='h-9 w-full sm:h-8 sm:w-[150px] lg:w-[250px]'
     />
   ) : (
     <Input
       placeholder={resolvedSearchPlaceholder}
       value={table.getState().globalFilter ?? ''}
       onChange={(event) => table.setGlobalFilter(event.target.value)}
-      className='h-8 w-full sm:w-[150px] lg:w-[250px]'
+      className='h-9 w-full sm:h-8 sm:w-[150px] lg:w-[250px]'
     />
   )
 
@@ -106,7 +106,7 @@ export function DataTableToolbar<TData>({
 
   return (
     <div className='space-y-2'>
-      <div className='flex items-center gap-2'>
+      <div className='flex items-center gap-1.5 sm:gap-2'>
         {/* Search input */}
         {customSearch !== undefined ? customSearch : searchInput}
 
@@ -122,7 +122,7 @@ export function DataTableToolbar<TData>({
           <Button
             variant='outline'
             size='sm'
-            className='relative h-8 shrink-0 gap-1 sm:hidden'
+            className='relative h-9 shrink-0 gap-1 px-2 sm:hidden'
             onClick={() => setMobileFiltersOpen((v) => !v)}
           >
             <SlidersHorizontal className='h-3.5 w-3.5' />
@@ -142,7 +142,7 @@ export function DataTableToolbar<TData>({
 
       {/* Mobile: collapsible filter area */}
       {hasFilterContent && mobileFiltersOpen && (
-        <div className='flex flex-wrap items-center gap-2 sm:hidden'>
+        <div className='bg-muted/30 flex flex-wrap items-center gap-2 rounded-lg border p-2 sm:hidden'>
           {additionalSearch && <div className='w-full'>{additionalSearch}</div>}
           {filterChips}
           {resetButton}

+ 2 - 2
web/default/src/components/data-table/view-options.tsx

@@ -25,10 +25,10 @@ export function DataTableViewOptions<TData>({
         <Button
           variant='outline'
           size='sm'
-          className='ms-auto hidden h-8 lg:flex'
+          className='ms-auto h-9 w-9 px-0 sm:h-8 sm:w-auto sm:px-3 lg:flex'
         >
           <MixerHorizontalIcon className='size-4' />
-          {t('View')}
+          <span className='hidden sm:inline'>{t('View')}</span>
         </Button>
       </DropdownMenuTrigger>
       <DropdownMenuContent align='end' className='w-[150px]'>

+ 9 - 7
web/default/src/components/layout/components/section-page-layout.tsx

@@ -70,15 +70,15 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
       <AppHeader />
 
       <Main>
-        <div className='shrink-0 px-4 pt-4 pb-3 sm:pt-6 sm:pb-4'>
-          {breadcrumb != null && <div className='mb-3'>{breadcrumb}</div>}
-          <div className='flex flex-wrap items-center justify-between gap-x-4 gap-y-2'>
+        <div className='shrink-0 px-3 pt-3 pb-2.5 sm:px-4 sm:pt-6 sm:pb-4'>
+          {breadcrumb != null && <div className='mb-2 sm:mb-3'>{breadcrumb}</div>}
+          <div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
             <div className='min-w-0'>
-              <h2 className='text-base font-bold tracking-tight sm:text-lg'>
+              <h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
                 {title}
               </h2>
               {description != null && (
-                <p className='text-muted-foreground max-sm:text-xs sm:text-sm'>
+                <p className='text-muted-foreground line-clamp-2 max-sm:text-xs sm:text-sm'>
                   {description}
                 </p>
               )}
@@ -91,11 +91,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
           </div>
         </div>
 
-        <div className='min-h-0 flex-1 overflow-auto px-4 pb-4'>{content}</div>
+        <div className='min-h-0 flex-1 overflow-auto px-3 pb-3 sm:px-4 sm:pb-4'>
+          {content}
+        </div>
 
         <div
           ref={setFooterContainer}
-          className='bg-background shrink-0 border-t px-4 py-3 empty:hidden'
+          className='bg-background shrink-0 border-t px-3 py-2.5 empty:hidden sm:px-4 sm:py-3'
         />
       </Main>
     </PageFooterProvider>

+ 81 - 0
web/default/src/components/ui/titled-card.tsx

@@ -0,0 +1,81 @@
+import type { ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from './card'
+
+type TitledCardProps = {
+  title: ReactNode
+  description?: ReactNode
+  icon?: ReactNode
+  action?: ReactNode
+  children?: ReactNode
+  className?: string
+  headerClassName?: string
+  contentClassName?: string
+  iconClassName?: string
+  titleClassName?: string
+  descriptionClassName?: string
+}
+
+export function TitledCard({
+  title,
+  description,
+  icon,
+  action,
+  children,
+  className,
+  headerClassName,
+  contentClassName,
+  iconClassName,
+  titleClassName,
+  descriptionClassName,
+}: TitledCardProps) {
+  return (
+    <Card 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)}
+      >
+        <div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
+          <div className='flex min-w-0 items-center gap-3'>
+            {icon != null && (
+              <div
+                className={cn(
+                  'bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9',
+                  iconClassName
+                )}
+              >
+                {icon}
+              </div>
+            )}
+            <div className='min-w-0'>
+              <CardTitle
+                className={cn('text-lg tracking-tight sm:text-xl', titleClassName)}
+              >
+                {title}
+              </CardTitle>
+              {description != null && (
+                <CardDescription
+                  className={cn(
+                    'text-xs sm:text-sm',
+                    descriptionClassName
+                  )}
+                >
+                  {description}
+                </CardDescription>
+              )}
+            </div>
+          </div>
+          {action != null && <div className='w-full shrink-0 sm:w-auto'>{action}</div>}
+        </div>
+      </CardHeader>
+      <CardContent className={cn('p-3 sm:p-5', contentClassName)}>
+        {children}
+      </CardContent>
+    </Card>
+  )
+}

+ 19 - 3
web/default/src/features/auth/hooks/use-auth-redirect.ts

@@ -5,6 +5,24 @@ import { getSelf } from '@/lib/api'
 import type { User } from '@/features/users/types'
 import { saveUserId } from '../lib/storage'
 
+function getSavedLanguage(user: User): string | undefined {
+  const userData = user as Record<string, unknown>
+  if (typeof userData.language === 'string') {
+    return userData.language
+  }
+
+  if (typeof userData.setting !== 'string') {
+    return undefined
+  }
+
+  try {
+    const setting = JSON.parse(userData.setting) as { language?: unknown }
+    return typeof setting.language === 'string' ? setting.language : undefined
+  } catch {
+    return undefined
+  }
+}
+
 /**
  * Hook for handling authentication redirects and user data management
  */
@@ -39,9 +57,7 @@ export function useAuthRedirect() {
         }
 
         // Restore saved language preference
-        const savedLang = (user as Record<string, unknown>).language as
-          | string
-          | undefined
+        const savedLang = getSavedLanguage(user)
         if (savedLang && savedLang !== i18n.language) {
           i18n.changeLanguage(savedLang)
         }

+ 5 - 2
web/default/src/features/channels/components/channels-table.tsx

@@ -87,7 +87,10 @@ export function ChannelsTable() {
   } = useTableUrlState({
     search: route.useSearch(),
     navigate: route.useNavigate(),
-    pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
+    pagination: {
+      defaultPage: 1,
+      defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
+    },
     globalFilter: { enabled: true, key: 'filter' },
     columnFilters: [
       { columnId: 'status', searchKey: 'status', type: 'array' },
@@ -329,7 +332,7 @@ export function ChannelsTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         <DataTableToolbar
           table={table}
           searchPlaceholder={t('Filter by name, ID, or key...')}

+ 5 - 5
web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx

@@ -1082,8 +1082,8 @@ export function ChannelMutateDrawer({
   return (
     <>
       <Sheet open={open} onOpenChange={handleOpenChange}>
-        <SheetContent className='flex w-full flex-col sm:max-w-3xl'>
-          <SheetHeader className='text-start'>
+        <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl'>
+          <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
             <SheetTitle className='flex items-center gap-3'>
               <span className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border'>
                 {getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)}
@@ -1110,10 +1110,10 @@ export function ChannelMutateDrawer({
             <form
               id='channel-form'
               onSubmit={form.handleSubmit(onSubmit)}
-              className='flex-1 space-y-5 overflow-y-auto px-4 pb-2'
+              className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-5 sm:px-4'
             >
               {/* ── Basic Information ── */}
-              <div className='bg-card space-y-4 rounded-xl border p-5'>
+              <div className='bg-card space-y-4 rounded-xl border p-3 sm:p-5'>
                 <CardHeading
                   title={t('Basic Information')}
                   icon={<Server className='h-4 w-4' />}
@@ -3276,7 +3276,7 @@ export function ChannelMutateDrawer({
             </form>
           </Form>
 
-          <SheetFooter className='gap-2'>
+          <SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
             <SheetClose asChild>
               <Button variant='outline' disabled={isSubmitting}>
                 {t('Cancel')}

+ 4 - 4
web/default/src/features/dashboard/components/models/consumption-distribution-chart.tsx

@@ -77,7 +77,7 @@ export function ConsumptionDistributionChart(
 
   return (
     <div className='overflow-hidden rounded-lg border'>
-      <div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
+      <div className='flex w-full flex-col gap-1.5 border-b px-3 py-2 sm:gap-3 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
         <div className='flex items-center gap-2'>
           <WalletCards className='text-muted-foreground/60 size-4' />
           <div className='text-sm font-semibold'>
@@ -88,7 +88,7 @@ export function ConsumptionDistributionChart(
           </span>
         </div>
 
-        <div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
+        <div className='bg-muted/60 inline-flex h-7 w-full overflow-x-auto rounded-md border p-0.5 sm:h-8 sm:w-auto'>
           {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => {
             const Icon = CHART_TYPE_ICONS[item.value]
             return (
@@ -96,7 +96,7 @@ export function ConsumptionDistributionChart(
                 key={item.value}
                 type='button'
                 onClick={() => setChartType(item.value)}
-                className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${
+                className={`inline-flex shrink-0 items-center gap-1.5 rounded-[5px] px-3 text-xs font-medium transition-colors ${
                   chartType === item.value
                     ? 'bg-background text-foreground shadow-sm'
                     : 'text-muted-foreground hover:text-foreground'
@@ -110,7 +110,7 @@ export function ConsumptionDistributionChart(
         </div>
       </div>
 
-      <div className='h-96 p-2'>
+      <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
         {themeReady && spec && (
           <VChart
             key={`${chartType}-${resolvedTheme}`}

+ 7 - 4
web/default/src/features/dashboard/components/models/log-stat-cards.tsx

@@ -95,10 +95,13 @@ export function LogStatCards(props: LogStatCardsProps) {
   return (
     <div className='overflow-hidden rounded-lg border'>
       <div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
-        {items.map((it) => {
+        {items.map((it, idx) => {
           const Icon = it.icon
           return (
-            <div key={it.title} className='px-4 py-3.5 sm:px-5 sm:py-4'>
+            <div
+              key={it.title}
+              className={`px-3 py-2.5 sm:px-5 sm:py-4 ${idx === items.length - 1 && items.length % 2 !== 0 ? 'col-span-2 sm:col-span-1' : ''}`}
+            >
               <div className='flex items-center gap-2'>
                 <Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
                 <div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
@@ -113,7 +116,7 @@ export function LogStatCards(props: LogStatCardsProps) {
                 </div>
               ) : error ? (
                 <>
-                  <div className='text-muted-foreground mt-2 font-mono text-2xl font-bold tracking-tight tabular-nums'>
+                  <div className='text-muted-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
                     --
                   </div>
                   <div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'>
@@ -122,7 +125,7 @@ export function LogStatCards(props: LogStatCardsProps) {
                 </>
               ) : (
                 <>
-                  <div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight tabular-nums'>
+                  <div className='text-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
                     {it.value}
                   </div>
                   <div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>

+ 4 - 4
web/default/src/features/dashboard/components/models/model-charts.tsx

@@ -78,7 +78,7 @@ export function ModelCharts(props: ModelChartsProps) {
 
   return (
     <div className='overflow-hidden rounded-lg border'>
-      <div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
+      <div className='flex w-full flex-col gap-1.5 border-b px-3 py-2 sm:gap-3 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
         <div className='flex items-center gap-2'>
           <PieChartIcon className='text-muted-foreground/60 size-4' />
           <div className='text-sm font-semibold'>
@@ -89,13 +89,13 @@ export function ModelCharts(props: ModelChartsProps) {
           </span>
         </div>
 
-        <div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
+        <div className='bg-muted/60 inline-flex h-7 w-full overflow-x-auto rounded-md border p-0.5 sm:h-8 sm:w-auto'>
           {MODEL_ANALYTICS_CHART_OPTIONS.map((tab) => (
             <button
               key={tab.value}
               type='button'
               onClick={() => setActiveTab(tab.value)}
-              className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
+              className={`shrink-0 rounded-[5px] px-3 text-xs font-medium transition-colors ${
                 activeTab === tab.value
                   ? 'bg-background text-foreground shadow-sm'
                   : 'text-muted-foreground hover:text-foreground'
@@ -107,7 +107,7 @@ export function ModelCharts(props: ModelChartsProps) {
         </div>
       </div>
 
-      <div className='h-96 p-2'>
+      <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
         {themeReady && spec && (
           <VChart
             key={`${activeTab}-${resolvedTheme}`}

+ 9 - 9
web/default/src/features/dashboard/components/models/models-filter-dialog.tsx

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
 import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { useAuthStore } from '@/stores/auth-store'
-import { getNormalizedDateRange, type TimeGranularity } from '@/lib/time'
+import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
 import { cn } from '@/lib/utils'
 import { Button } from '@/components/ui/button'
 import {
@@ -88,7 +88,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
 
   const handleReset = () => {
     const days = props.preferences.defaultTimeRangeDays
-    const { start, end } = getNormalizedDateRange(days)
+    const { start, end } = getRollingDateRange(days)
     setFilters({
       ...buildDefaultDashboardFilters(props.preferences),
       start_timestamp: start,
@@ -109,7 +109,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
   }
 
   const handleQuickRange = (days: number) => {
-    const { start, end } = getNormalizedDateRange(days)
+    const { start, end } = getRollingDateRange(days)
 
     setFilters((prev) => ({
       ...prev,
@@ -127,7 +127,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
           {t('Filter')}
         </Button>
       </DialogTrigger>
-      <DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-lg'>
+      <DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
         <DialogHeader>
           <DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
           <DialogDescription>
@@ -137,15 +137,15 @@ export function ModelsFilter(props: ModelsFilterProps) {
           </DialogDescription>
         </DialogHeader>
 
-        <ScrollArea className='flex-1 pr-4'>
-          <div className='grid gap-4 py-4'>
+        <ScrollArea className='flex-1 pr-3 sm:pr-4'>
+          <div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
             {/* Quick time range selection */}
             <div className='grid gap-2'>
               <Label className='flex items-center gap-2'>
                 <Calendar className='h-4 w-4' />
                 {t('Quick Range')}
               </Label>
-              <div className='flex gap-2'>
+              <div className='grid grid-cols-2 gap-2 sm:flex'>
                 {TIME_RANGE_PRESETS.map((range) => (
                   <Button
                     key={range.days}
@@ -170,7 +170,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
             <SectionDivider label={t('Custom Time Range')} />
 
             {/* Custom time range */}
-            <div className='grid gap-4'>
+            <div className='grid gap-3 sm:gap-4'>
               <div className='grid gap-2'>
                 <Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
                 <DateTimePicker
@@ -236,7 +236,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
           </div>
         </ScrollArea>
 
-        <DialogFooter>
+        <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
           <Button onClick={handleReset} variant='outline' type='button'>
             <RotateCcw className='mr-2 h-4 w-4' />
             {t('Reset')}

+ 4 - 4
web/default/src/features/dashboard/components/overview/announcements-panel.tsx

@@ -47,10 +47,10 @@ export function AnnouncementsPanel() {
       loading={loading}
       empty={!list.length}
       emptyMessage={t('No announcements at this time')}
-      height='h-64'
+      height='h-56 sm:h-64'
     >
-      <ScrollArea className='h-64'>
-        <div className='-mx-4 sm:-mx-5'>
+      <ScrollArea className='h-56 sm:h-64'>
+        <div className='-mx-3 sm:-mx-5'>
           {list.map((item: AnnouncementItem, idx: number) => {
             const key = item.id ?? `announcement-${idx}`
             return (
@@ -59,7 +59,7 @@ export function AnnouncementsPanel() {
                 type='button'
                 onClick={() => handleAnnouncementClick(item)}
                 className={cn(
-                  'group hover:bg-muted/40 w-full px-4 py-3.5 text-left transition-colors sm:px-5',
+                  'group hover:bg-muted/40 w-full px-3 py-3 text-left transition-colors sm:px-5 sm:py-3.5',
                   idx < list.length - 1 && 'border-border/60 border-b'
                 )}
               >

+ 4 - 4
web/default/src/features/dashboard/components/overview/api-info-item.tsx

@@ -23,8 +23,8 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
   const status = props.status
 
   return (
-    <div className='group hover:bg-muted/40 flex items-center justify-between gap-3 px-4 py-3 transition-colors sm:px-5'>
-      <div className='flex min-w-0 flex-1 items-center gap-3'>
+    <div className='group hover:bg-muted/40 flex items-center justify-between gap-2 px-3 py-2.5 transition-colors sm:gap-3 sm:px-5 sm:py-3'>
+      <div className='flex min-w-0 flex-1 items-center gap-2 sm:gap-3'>
         <span
           className={cn(
             'inline-block size-2 shrink-0 rounded-full',
@@ -91,7 +91,7 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
             variant='ghost'
             size='sm'
             onClick={() => openExternalSpeedTest(item.url)}
-            className='size-7 p-0'
+            className='hidden size-7 p-0 sm:inline-flex'
             title={t('External Speed Test')}
           >
             <Gauge className='size-3.5' />
@@ -111,7 +111,7 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
             variant='ghost'
             size='sm'
             asChild
-            className='size-7 p-0'
+            className='hidden size-7 p-0 sm:inline-flex'
             title={t('Open in New Tab')}
           >
             <a href={item.url} target='_blank' rel='noreferrer'>

+ 3 - 3
web/default/src/features/dashboard/components/overview/api-info-panel.tsx

@@ -37,10 +37,10 @@ export function ApiInfoPanel() {
       loading={loading}
       empty={!list.length}
       emptyMessage={t('No API routes configured')}
-      height='h-64'
+      height='h-56 sm:h-64'
     >
-      <ScrollArea className='h-64'>
-        <div className='-mx-4 sm:-mx-5'>
+      <ScrollArea className='h-56 sm:h-64'>
+        <div className='-mx-3 sm:-mx-5'>
           {list.map((item: ApiInfoItem, idx: number) => (
             <div
               key={item.url}

+ 2 - 2
web/default/src/features/dashboard/components/overview/faq-panel.tsx

@@ -27,9 +27,9 @@ export function FAQPanel() {
       loading={loading}
       empty={!list.length}
       emptyMessage={t('No FAQ entries available')}
-      height='h-80'
+      height='h-64 sm:h-80'
     >
-      <ScrollArea className='h-80'>
+      <ScrollArea className='h-64 sm:h-80'>
         <Accordion type='single' collapsible className='w-full'>
           {list.map((item: FAQItem, idx: number) => {
             const key = item.id ?? `faq-${idx}`

+ 4 - 9
web/default/src/features/dashboard/components/overview/summary-cards.tsx

@@ -53,14 +53,9 @@ export function SummaryCards() {
 
   return (
     <div className='overflow-hidden rounded-lg border'>
-      <StaggerContainer className='grid sm:grid-cols-2 lg:grid-cols-3'>
-        {items.map((it, idx) => (
-          <StaggerItem
-            key={it.title}
-            className={`px-4 sm:px-5 ${
-              idx > 0 ? 'border-t sm:border-t-0 sm:border-l' : ''
-            }`}
-          >
+      <StaggerContainer className='divide-border/60 grid grid-cols-3 divide-x'>
+        {items.map((it) => (
+          <StaggerItem key={it.title} className='px-3 py-3 sm:px-5 sm:py-4'>
             <StatCard
               title={it.title}
               value={it.value}
@@ -72,7 +67,7 @@ export function SummaryCards() {
                   <Button
                     variant='outline'
                     size='sm'
-                    className='h-6 gap-1 px-2 text-xs'
+                    className='hidden h-6 gap-1 px-2 text-xs sm:inline-flex'
                     asChild
                   >
                     <Link to='/wallet'>

+ 5 - 5
web/default/src/features/dashboard/components/overview/uptime-panel.tsx

@@ -84,7 +84,7 @@ export function UptimePanel() {
       loading={loading}
       empty={!groups.length}
       emptyMessage={t('No uptime monitoring configured')}
-      height='h-80'
+      height='h-64 sm:h-80'
       headerActions={
         <Button
           variant='ghost'
@@ -100,11 +100,11 @@ export function UptimePanel() {
         </Button>
       }
     >
-      <ScrollArea className='h-80'>
-        <div className='-mx-4 space-y-0 sm:-mx-5'>
+      <ScrollArea className='h-64 sm:h-80'>
+        <div className='-mx-3 space-y-0 sm:-mx-5'>
           {groups.map((group, groupIdx) => (
             <div key={group.categoryName}>
-              <div className='bg-muted/30 border-border/60 border-b px-4 py-2 sm:px-5'>
+              <div className='bg-muted/30 border-border/60 border-b px-3 py-2 sm:px-5'>
                 <div className='flex items-center gap-2'>
                   <h4 className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
                     {group.categoryName}
@@ -120,7 +120,7 @@ export function UptimePanel() {
                   <div
                     key={monitor.name}
                     className={cn(
-                      'hover:bg-muted/40 flex items-center justify-between px-4 py-2.5 transition-colors sm:px-5',
+                      'hover:bg-muted/40 flex items-center justify-between gap-2 px-3 py-2 transition-colors sm:px-5 sm:py-2.5',
                       monitorIdx < (group.monitors?.length || 0) - 1 &&
                         'border-border/40 border-b',
                       groupIdx < groups.length - 1 &&

+ 6 - 6
web/default/src/features/dashboard/components/ui/panel-wrapper.tsx

@@ -20,10 +20,10 @@ export function PanelWrapper(props: PanelWrapperProps) {
   if (props.loading) {
     return (
       <div className='overflow-hidden rounded-lg border'>
-        <div className='border-b px-4 py-3 sm:px-5'>
+        <div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
           <div className='text-sm font-semibold'>{props.title}</div>
         </div>
-        <div className='p-4 sm:p-5'>
+        <div className='p-3 sm:p-5'>
           <Skeleton className={`w-full ${height}`} />
         </div>
       </div>
@@ -33,7 +33,7 @@ export function PanelWrapper(props: PanelWrapperProps) {
   if (props.empty) {
     return (
       <div className='overflow-hidden rounded-lg border'>
-        <div className='border-b px-4 py-3 sm:px-5'>
+        <div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
           <div className='text-sm font-semibold'>{props.title}</div>
         </div>
         <div
@@ -47,9 +47,9 @@ export function PanelWrapper(props: PanelWrapperProps) {
 
   return (
     <div className='overflow-hidden rounded-lg border'>
-      <div className='border-b px-4 py-3 sm:px-5'>
+      <div className='border-b px-3 py-2.5 sm:px-5 sm:py-3'>
         {props.headerActions ? (
-          <div className='flex items-center justify-between'>
+          <div className='flex items-center justify-between gap-2'>
             <div className='text-sm font-semibold'>{props.title}</div>
             {props.headerActions}
           </div>
@@ -57,7 +57,7 @@ export function PanelWrapper(props: PanelWrapperProps) {
           <div className='text-sm font-semibold'>{props.title}</div>
         )}
       </div>
-      <div className='p-4 sm:p-5'>{props.children}</div>
+      <div className='p-3 sm:p-5'>{props.children}</div>
     </div>
   )
 }

+ 12 - 10
web/default/src/features/dashboard/components/ui/stat-card.tsx

@@ -15,13 +15,15 @@ export function StatCard(props: StatCardProps) {
   const Icon = props.icon
 
   return (
-    <div className='group flex flex-col gap-1.5 py-3'>
-      <div className='flex items-center justify-between'>
-        <div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
-          <Icon className='text-muted-foreground/60 size-3.5' />
-          {props.title}
+    <div className='group flex flex-col gap-1'>
+      <div className='flex items-start justify-between gap-1'>
+        <div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium tracking-wider uppercase sm:gap-2'>
+          <Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
+          <span className='line-clamp-2 leading-snug'>{props.title}</span>
         </div>
-        {props.action}
+        {props.action && (
+          <div className='shrink-0'>{props.action}</div>
+        )}
       </div>
 
       {props.loading ? (
@@ -31,19 +33,19 @@ export function StatCard(props: StatCardProps) {
         </div>
       ) : props.error ? (
         <>
-          <div className='text-muted-foreground font-mono text-2xl font-bold tracking-tight tabular-nums'>
+          <div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
             --
           </div>
-          <p className='text-muted-foreground/60 text-xs'>
+          <p className='text-muted-foreground/60 hidden text-xs md:block'>
             {props.description}
           </p>
         </>
       ) : (
         <>
-          <div className='text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums'>
+          <div className='text-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
             {props.value}
           </div>
-          <p className='text-muted-foreground/60 text-xs'>
+          <p className='text-muted-foreground/60 hidden text-xs md:block'>
             {props.description}
           </p>
         </>

+ 11 - 12
web/default/src/features/dashboard/components/users/user-charts.tsx

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
 import { VChart } from '@visactor/react-vchart'
 import { Users, Loader2 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
-import { getNormalizedDateRange, type TimeGranularity } from '@/lib/time'
+import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
 import { VCHART_OPTION } from '@/lib/vchart'
 import { useTheme } from '@/context/theme-provider'
 import { Skeleton } from '@/components/ui/skeleton'
@@ -60,7 +60,7 @@ export function UserCharts() {
   const [topUserLimit, setTopUserLimit] = useState(10)
   const [timeRange, setTimeRange] = useState(() => {
     const days = getDefaultDays(timeGranularity)
-    const { start, end } = getNormalizedDateRange(days)
+    const { start, end } = getRollingDateRange(days)
     return {
       start_timestamp: Math.floor(start.getTime() / 1000),
       end_timestamp: Math.floor(end.getTime() / 1000),
@@ -69,7 +69,7 @@ export function UserCharts() {
 
   const handleRangeChange = useCallback((days: number) => {
     setSelectedRange(days)
-    const { start, end } = getNormalizedDateRange(days)
+    const { start, end } = getRollingDateRange(days)
     setTimeRange({
       start_timestamp: Math.floor(start.getTime() / 1000),
       end_timestamp: Math.floor(end.getTime() / 1000),
@@ -123,10 +123,9 @@ export function UserCharts() {
   )
 
   return (
-    <div className='space-y-4'>
-      {/* Toolbar: time range presets + granularity */}
-      <div className='flex flex-wrap items-center gap-2'>
-        <div className='flex items-center gap-1.5 rounded-md border p-0.5'>
+    <div className='space-y-3'>
+      <div className='flex items-center gap-1.5 overflow-x-auto pb-1 sm:gap-2'>
+        <div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
           {TIME_RANGE_PRESETS.map((preset) => (
             <button
               key={preset.days}
@@ -143,7 +142,7 @@ export function UserCharts() {
           ))}
         </div>
 
-        <div className='flex items-center gap-1.5 rounded-md border p-0.5'>
+        <div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
           {TIME_GRANULARITY_OPTIONS.map((opt) => (
             <button
               key={opt.value}
@@ -162,7 +161,7 @@ export function UserCharts() {
           ))}
         </div>
 
-        <div className='flex items-center gap-1.5 rounded-md border p-0.5'>
+        <div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
           <span className='text-muted-foreground px-2 text-xs font-medium'>
             {t('Top Users')}
           </span>
@@ -187,7 +186,7 @@ export function UserCharts() {
         )}
       </div>
 
-      <div className='grid gap-4'>
+      <div className='grid gap-3'>
         {USER_CHARTS.map((chart) => {
           const spec = chartData[chart.specKey]
 
@@ -196,12 +195,12 @@ export function UserCharts() {
               key={chart.value}
               className='overflow-hidden rounded-lg border'
             >
-              <div className='flex w-full items-center gap-2 border-b px-4 py-3 sm:px-5'>
+              <div className='flex w-full items-center gap-2 border-b px-3 py-2 sm:px-5 sm:py-3'>
                 <Users className='text-muted-foreground/60 size-4' />
                 <div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
               </div>
 
-              <div className='h-96 p-2'>
+              <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
                 {isLoading ? (
                   <Skeleton className='h-full w-full' />
                 ) : (

+ 2 - 6
web/default/src/features/dashboard/hooks/use-dashboard-config.tsx

@@ -76,9 +76,7 @@ export function useSummaryCardsConfig(totals: {
   return [
     {
       key: 'balance',
-      title: totals.currencyEnabled
-        ? `${t('Current Balance')} (${totals.currencyLabel})`
-        : t('Current Balance'),
+      title: t('Current Balance'),
       value: totals.remainDisplay,
       description: totals.currencyEnabled
         ? `${t('Remaining quota')} (${totals.currencyLabel})`
@@ -87,9 +85,7 @@ export function useSummaryCardsConfig(totals: {
     },
     {
       key: 'usage',
-      title: totals.currencyEnabled
-        ? `${t('Historical Usage')} (${totals.currencyLabel})`
-        : t('Historical Usage'),
+      title: t('Historical Usage'),
       value: totals.usedDisplay,
       description: totals.currencyEnabled
         ? `${t('Total consumed')} (${totals.currencyLabel})`

+ 4 - 4
web/default/src/features/dashboard/index.tsx

@@ -191,9 +191,9 @@ export function Dashboard() {
         {t(meta.descriptionKey)}
       </SectionPageLayout.Description>
       <SectionPageLayout.Content>
-        <div className='space-y-4'>
+        <div className='space-y-3 sm:space-y-4'>
           {activeSection !== 'overview' && (
-            <div className='flex flex-wrap items-center justify-between gap-2'>
+            <div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
               {showSectionTabs ? (
                 <Tabs value={activeSection} onValueChange={handleSectionChange}>
                   <TabsList className='h-auto max-w-full flex-wrap justify-start'>
@@ -208,7 +208,7 @@ export function Dashboard() {
                 <div />
               )}
               {modelActions != null && (
-                <div className='flex shrink-0 flex-wrap items-center gap-2'>
+                <div className='flex shrink-0 flex-wrap items-center gap-1.5 sm:gap-2'>
                   {modelActions}
                 </div>
               )}
@@ -217,7 +217,7 @@ export function Dashboard() {
           {activeSection === 'overview' && (
             <>
               <SummaryCards />
-              <CardStaggerContainer className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
+              <CardStaggerContainer className='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2'>
                 <CardStaggerItem>
                   <ApiInfoPanel />
                 </CardStaggerItem>

+ 2 - 2
web/default/src/features/dashboard/lib/filters.ts

@@ -1,5 +1,5 @@
 import type { TimeGranularity } from '@/lib/time'
-import { getNormalizedDateRange } from '@/lib/time'
+import { getRollingDateRange } from '@/lib/time'
 import {
   DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
   DEFAULT_DASHBOARD_CHART_PREFERENCES,
@@ -128,7 +128,7 @@ export function getDefaultDays(granularity?: TimeGranularity): number {
 export function buildDefaultDashboardFilters(
   preferences: DashboardChartPreferences = getSavedChartPreferences()
 ): DashboardFilters {
-  const { start, end } = getNormalizedDateRange(preferences.defaultTimeRangeDays)
+  const { start, end } = getRollingDateRange(preferences.defaultTimeRangeDays)
   return {
     ...EMPTY_DASHBOARD_FILTERS,
     start_timestamp: start,

+ 13 - 5
web/default/src/features/keys/components/api-key-group-combobox.tsx

@@ -62,7 +62,13 @@ function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
   if (!label) return null
 
   return (
-    <Badge variant='outline' className={getRatioBadgeClassName(ratio)}>
+    <Badge
+      variant='outline'
+      className={cn(
+        'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
+        getRatioBadgeClassName(ratio)
+      )}
+    >
       {label}
     </Badge>
   )
@@ -110,20 +116,22 @@ export function ApiKeyGroupCombobox({
           role='combobox'
           aria-expanded={open}
           disabled={disabled}
-          className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px]'
+          className='border-input bg-muted/40 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
         >
-          <span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
+          <span className='flex min-w-0 flex-1 items-center justify-between gap-2 sm:gap-3'>
             <span className='min-w-0'>
               <span className='block truncate font-medium'>
                 {selectedOption?.value || placeholder || t('Select a group')}
               </span>
               {selectedOption?.desc && (
-                <span className='text-muted-foreground block truncate text-xs'>
+                <span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
                   {selectedOption.desc}
                 </span>
               )}
             </span>
-            <GroupRatioBadge ratio={selectedOption?.ratio} />
+            <span className='hidden sm:block'>
+              <GroupRatioBadge ratio={selectedOption?.ratio} />
+            </span>
           </span>
           <ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
         </Button>

+ 32 - 25
web/default/src/features/keys/components/api-keys-mutate-drawer.tsx

@@ -79,18 +79,18 @@ function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
 
   return (
     <section className='bg-card rounded-lg border'>
-      <div className='flex items-center gap-3 border-b px-4 py-3'>
-        <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
-          <Icon className='size-5' />
+      <div className='flex items-center gap-2.5 border-b px-3 py-2.5 sm:gap-3 sm:px-4 sm:py-3'>
+        <div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
+          <Icon className='size-4 sm:size-5' />
         </div>
         <div className='min-w-0'>
           <h3 className='text-sm font-medium leading-none'>{props.title}</h3>
-          <p className='text-muted-foreground mt-1 text-xs'>
+          <p className='text-muted-foreground mt-0.5 text-xs sm:mt-1'>
             {props.description}
           </p>
         </div>
       </div>
-      <div className='space-y-4 p-4'>{props.children}</div>
+      <div className='space-y-3 p-3 sm:space-y-4 sm:p-4'>{props.children}</div>
     </section>
   )
 }
@@ -254,13 +254,13 @@ export function ApiKeysMutateDrawer({
     >
       <SheetContent
         side={side}
-        className='bg-background flex w-full gap-0 overflow-hidden p-0 sm:max-w-[620px]'
+        className='bg-background flex !h-dvh !w-screen max-w-none gap-0 overflow-hidden p-0 sm:!w-full sm:!max-w-[620px]'
       >
-        <SheetHeader className='bg-background border-b px-5 py-4 text-start'>
-          <SheetTitle className='text-lg'>
+        <SheetHeader className='bg-background border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
+          <SheetTitle className='text-base sm:text-lg'>
             {isUpdate ? t('Update API Key') : t('Create API Key')}
           </SheetTitle>
-          <SheetDescription>
+          <SheetDescription className='pr-6 text-xs sm:text-sm'>
             {isUpdate
               ? t('Update the API key by providing necessary info.')
               : t('Add a new API key by providing necessary info.')}{' '}
@@ -271,7 +271,7 @@ export function ApiKeysMutateDrawer({
           <form
             id='api-key-form'
             onSubmit={form.handleSubmit(onSubmit)}
-            className='flex-1 space-y-4 overflow-y-auto px-4 py-4'
+            className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3 sm:space-y-4 sm:px-4 sm:py-4'
           >
             <ApiKeyFormSection
               title={t('Basic Information')}
@@ -319,12 +319,12 @@ export function ApiKeysMutateDrawer({
                   control={form.control}
                   name='cross_group_retry'
                   render={({ field }) => (
-                    <FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
+                    <FormItem className='flex min-h-16 flex-row items-center justify-between gap-3 rounded-lg border px-3 py-2.5 sm:min-h-20 sm:gap-4 sm:px-4 sm:py-3'>
                       <div className='space-y-0.5'>
                         <FormLabel className='text-sm'>
                           {t('Cross-group retry')}
                         </FormLabel>
-                        <FormDescription className='text-xs'>
+                        <FormDescription className='line-clamp-2 text-xs sm:line-clamp-none'>
                           {t(
                             'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
                           )}
@@ -353,7 +353,7 @@ export function ApiKeysMutateDrawer({
                           value={field.value}
                           onChange={field.onChange}
                           placeholder={t('Never expires')}
-                          className='min-w-0'
+                          className='min-w-0 [&_input[type=time]]:w-24 sm:[&_input[type=time]]:w-32'
                         />
                       </FormControl>
                       <div className='grid grid-cols-4 gap-2 sm:flex'>
@@ -361,7 +361,7 @@ export function ApiKeysMutateDrawer({
                           type='button'
                           variant='outline'
                           size='sm'
-                          className='px-3'
+                          className='px-2 text-xs sm:px-3 sm:text-sm'
                           onClick={() => handleSetExpiry(0, 0, 0)}
                         >
                           {t('Never')}
@@ -370,7 +370,7 @@ export function ApiKeysMutateDrawer({
                           type='button'
                           variant='outline'
                           size='sm'
-                          className='px-3'
+                          className='px-2 text-xs sm:px-3 sm:text-sm'
                           onClick={() => handleSetExpiry(1, 0, 0)}
                         >
                           {t('1 Month')}
@@ -379,7 +379,7 @@ export function ApiKeysMutateDrawer({
                           type='button'
                           variant='outline'
                           size='sm'
-                          className='px-3'
+                          className='px-2 text-xs sm:px-3 sm:text-sm'
                           onClick={() => handleSetExpiry(0, 1, 0)}
                         >
                           {t('1 Day')}
@@ -388,7 +388,7 @@ export function ApiKeysMutateDrawer({
                           type='button'
                           variant='outline'
                           size='sm'
-                          className='px-3'
+                          className='px-2 text-xs sm:px-3 sm:text-sm'
                           onClick={() => handleSetExpiry(0, 0, 1)}
                         >
                           {t('1 Hour')}
@@ -470,7 +470,7 @@ export function ApiKeysMutateDrawer({
                 control={form.control}
                 name='unlimited_quota'
                 render={({ field }) => (
-                  <FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
+                  <FormItem className='flex min-h-16 flex-row items-center justify-between gap-3 rounded-lg border px-3 py-2.5 sm:min-h-20 sm:gap-4 sm:px-4 sm:py-3'>
                     <div className='space-y-0.5'>
                       <FormLabel className='text-sm'>
                         {t('Unlimited Quota')}
@@ -495,10 +495,10 @@ export function ApiKeysMutateDrawer({
                 <CollapsibleTrigger asChild>
                   <button
                     type='button'
-                    className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
+                    className='hover:bg-muted/50 flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors sm:gap-3 sm:px-4 sm:py-3'
                   >
-                    <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
-                      <Settings2 className='size-5' />
+                    <div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
+                      <Settings2 className='size-4 sm:size-5' />
                     </div>
                     <div className='min-w-0 flex-1'>
                       <h3 className='text-sm font-medium leading-none'>
@@ -517,7 +517,7 @@ export function ApiKeysMutateDrawer({
                   </button>
                 </CollapsibleTrigger>
                 <CollapsibleContent>
-                  <div className='space-y-4 border-t p-4'>
+                  <div className='space-y-3 border-t p-3 sm:space-y-4 sm:p-4'>
                     <FormField
                       control={form.control}
                       name='model_limits'
@@ -578,11 +578,18 @@ export function ApiKeysMutateDrawer({
             </Collapsible>
           </form>
         </Form>
-        <SheetFooter className='bg-background gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end'>
+        <SheetFooter className='bg-background grid grid-cols-2 gap-2 border-t px-3 py-3 sm:flex sm:flex-row sm:justify-end sm:px-5 sm:py-4'>
           <SheetClose asChild>
-            <Button variant='outline'>{t('Close')}</Button>
+            <Button variant='outline' className='w-full sm:w-auto'>
+              {t('Close')}
+            </Button>
           </SheetClose>
-          <Button form='api-key-form' type='submit' disabled={isSubmitting}>
+          <Button
+            form='api-key-form'
+            type='submit'
+            disabled={isSubmitting}
+            className='w-full sm:w-auto'
+          >
             {isSubmitting ? t('Saving...') : t('Save changes')}
           </Button>
         </SheetFooter>

+ 138 - 13
web/default/src/features/keys/components/api-keys-table.tsx

@@ -16,7 +16,9 @@ import {
 import { useMediaQuery } from '@/hooks'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
+import { formatQuota } from '@/lib/format'
 import { cn } from '@/lib/utils'
+import { Database } from 'lucide-react'
 import { useTableUrlState } from '@/hooks/use-table-url-state'
 import {
   Table,
@@ -33,16 +35,31 @@ import {
   DataTableToolbar,
   TableSkeleton,
   TableEmpty,
-  MobileCardList,
 } from '@/components/data-table'
+import {
+  Empty,
+  EmptyDescription,
+  EmptyHeader,
+  EmptyMedia,
+  EmptyTitle,
+} from '@/components/ui/empty'
 import { PageFooterPortal } from '@/components/layout'
+import { Skeleton } from '@/components/ui/skeleton'
+import { StatusBadge } from '@/components/status-badge'
 import { getApiKeys, searchApiKeys } from '../api'
-import { API_KEY_STATUS, API_KEY_STATUS_OPTIONS, ERROR_MESSAGES } from '../constants'
+import {
+  API_KEY_STATUS,
+  API_KEY_STATUS_OPTIONS,
+  API_KEY_STATUSES,
+  ERROR_MESSAGES,
+} from '../constants'
 import { type ApiKey } from '../types'
+import { ApiKeyCell } from './api-keys-cells'
 import { useApiKeysColumns } from './api-keys-columns'
 import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
 import { useApiKeys } from './api-keys-provider'
 import { DataTableBulkActions } from './data-table-bulk-actions'
+import { DataTableRowActions } from './data-table-row-actions'
 
 const route = getRouteApi('/_authenticated/keys/')
 
@@ -50,6 +67,123 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
   return apiKey.status !== API_KEY_STATUS.ENABLED
 }
 
+function ApiKeysMobileSkeleton() {
+  return (
+    <div className='divide-border overflow-hidden rounded-lg border'>
+      {Array.from({ length: 5 }).map((_, index) => (
+        <div
+          key={index}
+          className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
+        >
+          <div className='flex items-center justify-between'>
+            <Skeleton className='h-4 w-32' />
+            <Skeleton className='h-5 w-16 rounded-full' />
+          </div>
+          <div className='flex items-center justify-between gap-3'>
+            <Skeleton className='h-7 w-44' />
+            <Skeleton className='h-8 w-16' />
+          </div>
+          <Skeleton className='h-3 w-28' />
+        </div>
+      ))}
+    </div>
+  )
+}
+
+function ApiKeysMobileList({
+  table,
+  isLoading,
+}: {
+  table: ReturnType<typeof useReactTable<ApiKey>>
+  isLoading: boolean
+}) {
+  const { t } = useTranslation()
+  const rows = table.getRowModel().rows
+
+  if (isLoading) return <ApiKeysMobileSkeleton />
+
+  if (!rows.length) {
+    return (
+      <div className='rounded-lg border p-8'>
+        <Empty className='border-none p-0'>
+          <EmptyHeader>
+            <EmptyMedia variant='icon'>
+              <Database className='size-6' />
+            </EmptyMedia>
+            <EmptyTitle>{t('No API Keys Found')}</EmptyTitle>
+            <EmptyDescription>
+              {t(
+                'No API keys available. Create your first API key to get started.'
+              )}
+            </EmptyDescription>
+          </EmptyHeader>
+        </Empty>
+      </div>
+    )
+  }
+
+  return (
+    <div className='divide-border overflow-hidden rounded-lg border'>
+      {rows.map((row) => {
+        const apiKey = row.original
+        const statusConfig = API_KEY_STATUSES[apiKey.status]
+        const total = apiKey.used_quota + apiKey.remain_quota
+
+        return (
+          <div
+            key={row.id}
+            className={cn(
+              'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
+              isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
+            )}
+          >
+            <div className='flex items-start justify-between gap-3'>
+              <div className='min-w-0'>
+                <div className='truncate text-sm font-semibold'>
+                  {apiKey.name}
+                </div>
+                <div className='text-muted-foreground text-[11px]'>
+                  {t('API Key')}
+                </div>
+              </div>
+              {statusConfig && (
+                <StatusBadge
+                  label={t(statusConfig.label)}
+                  variant={statusConfig.variant}
+                  showDot={statusConfig.showDot}
+                  copyable={false}
+                />
+              )}
+            </div>
+
+            <div className='flex min-w-0 items-center justify-between gap-2'>
+              <div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
+                <ApiKeyCell apiKey={apiKey} />
+              </div>
+              <DataTableRowActions row={row} />
+            </div>
+
+            <div className='flex items-center justify-between gap-2 text-xs'>
+              <span className='text-muted-foreground'>{t('Quota')}</span>
+              {apiKey.unlimited_quota ? (
+                <span className='font-medium'>{t('Unlimited')}</span>
+              ) : (
+                <span className='font-medium tabular-nums'>
+                  {formatQuota(apiKey.remain_quota)}
+                  <span className='text-muted-foreground font-normal'>
+                    {' / '}
+                    {formatQuota(total)}
+                  </span>
+                </span>
+              )}
+            </div>
+          </div>
+        )
+      })}
+    </div>
+  )
+}
+
 export function ApiKeysTable() {
   const { t } = useTranslation()
   const { refreshTrigger } = useApiKeys()
@@ -166,7 +300,7 @@ export function ApiKeysTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         <div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
           <ApiKeysPrimaryButtons />
           <div className='min-w-0 sm:flex sm:justify-end'>
@@ -184,18 +318,9 @@ export function ApiKeysTable() {
           </div>
         </div>
         {isMobile ? (
-          <MobileCardList
+          <ApiKeysMobileList
             table={table}
             isLoading={isLoading}
-            emptyTitle={t('No API Keys Found')}
-            emptyDescription={t(
-              'No API keys available. Create your first API key to get started.'
-            )}
-            getRowClassName={(row) =>
-              isDisabledApiKeyRow(row.original)
-                ? DISABLED_ROW_MOBILE
-                : undefined
-            }
           />
         ) : (
           <div

+ 2 - 2
web/default/src/features/models/components/deployments-table.tsx

@@ -72,7 +72,7 @@ export function DeploymentsTable() {
       pageKey: 'dPage',
       pageSizeKey: 'dPageSize',
       defaultPage: 1,
-      defaultPageSize: 10,
+      defaultPageSize: isMobile ? 8 : 10,
     },
     globalFilter: { enabled: true, key: 'dFilter' },
     columnFilters: [
@@ -229,7 +229,7 @@ export function DeploymentsTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         <DataTableToolbar
           table={table}
           searchPlaceholder={t('Search deployments...')}

+ 7 - 7
web/default/src/features/models/components/dialogs/update-config-dialog.tsx

@@ -195,7 +195,7 @@ export function UpdateConfigDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
+      <DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
         <DialogHeader>
           <DialogTitle>{title}</DialogTitle>
         </DialogHeader>
@@ -205,14 +205,14 @@ export function UpdateConfigDialog({
             <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
           </div>
         ) : (
-          <div className='max-h-[72vh] overflow-y-auto py-2 pr-1'>
+          <div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
             <Form {...form}>
               <form
                 onSubmit={form.handleSubmit(onSubmit)}
                 autoComplete='off'
                 className='space-y-4'
               >
-                <div className='grid gap-4 md:grid-cols-2'>
+                <div className='grid gap-3 md:grid-cols-2 md:gap-4'>
                   <FormField
                     control={form.control}
                     name='image_url'
@@ -262,7 +262,7 @@ export function UpdateConfigDialog({
                   />
                 </div>
 
-                <div className='grid gap-4 md:grid-cols-2'>
+                <div className='grid gap-3 md:grid-cols-2 md:gap-4'>
                   <FormField
                     control={form.control}
                     name='entrypoint'
@@ -313,7 +313,7 @@ export function UpdateConfigDialog({
                     {t('Registry (optional)')}
                   </CollapsibleTrigger>
                   <CollapsibleContent>
-                    <div className='mt-3 grid gap-4 md:grid-cols-2'>
+                    <div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
                       <FormField
                         control={form.control}
                         name='registry_username'
@@ -353,7 +353,7 @@ export function UpdateConfigDialog({
                     {t('Environment variables')}
                   </CollapsibleTrigger>
                   <CollapsibleContent>
-                    <div className='mt-3 grid gap-4 md:grid-cols-2'>
+                    <div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
                       <FormField
                         control={form.control}
                         name='env_json'
@@ -394,7 +394,7 @@ export function UpdateConfigDialog({
                   </CollapsibleContent>
                 </Collapsible>
 
-                <DialogFooter className='pt-2'>
+                <DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
                   <Button
                     type='button'
                     variant='outline'

+ 5 - 5
web/default/src/features/models/components/dialogs/view-details-dialog.tsx

@@ -99,18 +99,18 @@ export function ViewDetailsDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
+      <DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
         <DialogHeader>
           <DialogTitle>{t('Deployment details')}</DialogTitle>
         </DialogHeader>
 
-        <div className='max-h-[72vh] space-y-4 overflow-y-auto py-2 pr-1'>
-          <div className='flex flex-wrap items-center justify-between gap-2'>
+        <div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
+            <div className='flex flex-wrap items-center justify-between gap-2'>
             <div className='text-muted-foreground text-sm'>
               {t('Deployment ID')}:{' '}
               <span className='font-mono'>{deploymentId}</span>
             </div>
-            <div className='flex items-center gap-2'>
+            <div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
               <Button variant='outline' size='sm' onClick={handleCopyId}>
                 <Copy className='mr-2 h-4 w-4' />
                 {t('Copy')}
@@ -252,7 +252,7 @@ export function ViewDetailsDialog({
         </div>
 
         <DialogFooter>
-          <Button variant='outline' onClick={() => onOpenChange(false)}>
+          <Button variant='outline' onClick={() => onOpenChange(false)} className='w-full sm:w-auto'>
             {t('Close')}
           </Button>
         </DialogFooter>

+ 6 - 6
web/default/src/features/models/components/dialogs/view-logs-dialog.tsx

@@ -124,7 +124,7 @@ export function ViewLogsDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className='flex h-[80vh] max-w-4xl flex-col'>
+      <DialogContent className='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'>
         <DialogHeader>
           <DialogTitle className='flex items-center gap-2'>
             <Terminal className='h-5 w-5' />
@@ -132,11 +132,11 @@ export function ViewLogsDialog({
           </DialogTitle>
         </DialogHeader>
 
-        <div className='mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
+        <div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
           <div className='text-muted-foreground text-sm'>
             {t('Deployment ID')}: {deploymentId}
           </div>
-          <div className='flex flex-wrap items-center gap-2'>
+          <div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
             <Button
               variant='outline'
               size='sm'
@@ -162,14 +162,14 @@ export function ViewLogsDialog({
               <Download className='mr-2 h-4 w-4' />
               {t('Download')}
             </Button>
-            <div className='flex items-center gap-2 rounded-md border px-3 py-1.5'>
+            <div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
               <span className='text-xs'>{t('Auto refresh')}</span>
               <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
             </div>
           </div>
         </div>
 
-        <div className='mb-3 grid gap-3 sm:grid-cols-2'>
+        <div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
           <div className='space-y-1'>
             <div className='text-muted-foreground text-xs'>
               {t('Container')}
@@ -234,7 +234,7 @@ export function ViewLogsDialog({
 
         <div
           ref={scrollRef}
-          className='flex-1 overflow-auto rounded-md border bg-black p-4'
+          className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
           onScroll={(e) => {
             const target = e.target as HTMLDivElement
             const isAtBottom =

+ 4 - 4
web/default/src/features/models/components/drawers/model-mutate-drawer.tsx

@@ -601,8 +601,8 @@ export function ModelMutateDrawer({
 
   return (
     <Sheet open={open} onOpenChange={onOpenChange}>
-      <SheetContent className='flex w-full flex-col sm:max-w-2xl'>
-        <SheetHeader className='text-start'>
+      <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
+        <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
           <SheetTitle>
             {isEditing ? t('Edit Model') : t('Create Model')}
           </SheetTitle>
@@ -621,7 +621,7 @@ export function ModelMutateDrawer({
             onSubmit={form.handleSubmit(
               onSubmit as Parameters<typeof form.handleSubmit>[0]
             )}
-            className='flex-1 space-y-6 overflow-y-auto px-4'
+            className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
           >
             {/* Basic Information */}
             <div className='space-y-4'>
@@ -1232,7 +1232,7 @@ export function ModelMutateDrawer({
           </form>
         </Form>
 
-        <SheetFooter className='gap-2'>
+        <SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
           <SheetClose asChild>
             <Button variant='outline' disabled={isSubmitting}>
               {t('Cancel')}

+ 5 - 5
web/default/src/features/models/components/drawers/prefill-group-form-drawer.tsx

@@ -161,8 +161,8 @@ export function PrefillGroupFormDrawer({
 
   return (
     <Sheet open={open} onOpenChange={handleOpenChange}>
-      <SheetContent className='flex w-full flex-col sm:max-w-2xl'>
-        <SheetHeader className='text-start'>
+      <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
+        <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
           <SheetTitle>
             {isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')}
           </SheetTitle>
@@ -177,7 +177,7 @@ export function PrefillGroupFormDrawer({
           <form
             id='prefill-group-form'
             onSubmit={form.handleSubmit(handleSubmit)}
-            className='flex-1 space-y-6 overflow-y-auto px-4'
+            className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
           >
             <div className='space-y-4'>
               <div className='space-y-1'>
@@ -286,7 +286,7 @@ export function PrefillGroupFormDrawer({
                 )}
               />
 
-              <div className='space-y-2 rounded-lg border p-4'>
+              <div className='space-y-2 rounded-lg border p-3 sm:p-4'>
                 <div className='flex items-center gap-2'>
                   <h4 className='text-sm font-medium'>{t('Project')}</h4>
                   <StatusBadge
@@ -343,7 +343,7 @@ export function PrefillGroupFormDrawer({
           </form>
         </Form>
 
-        <SheetFooter className='gap-2'>
+        <SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
           <SheetClose asChild>
             <Button type='button' variant='outline' disabled={isSaving}>
               {t('Cancel')}

+ 5 - 2
web/default/src/features/models/components/models-table.tsx

@@ -67,7 +67,10 @@ export function ModelsTable() {
   } = useTableUrlState({
     search: route.useSearch(),
     navigate: route.useNavigate(),
-    pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
+    pagination: {
+      defaultPage: 1,
+      defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
+    },
     globalFilter: { enabled: true, key: 'filter' },
     columnFilters: [
       { columnId: 'status', searchKey: 'status', type: 'array' },
@@ -217,7 +220,7 @@ export function ModelsTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         <DataTableToolbar
           table={table}
           searchPlaceholder={t('Filter by model name...')}

+ 64 - 4
web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx

@@ -206,8 +206,8 @@ export function DynamicPricingBreakdown({
   })
 
   return (
-    <section className='min-w-0 py-4'>
-      <div className='mb-4 flex items-start gap-2'>
+    <section className='min-w-0 py-3 sm:py-4'>
+      <div className='mb-3 flex items-start gap-2 sm:mb-4'>
         <span className='mt-0.5 inline-flex size-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 shadow-sm dark:bg-amber-500/20 dark:text-amber-300'>
           <TagIcon className='size-3.5' />
         </span>
@@ -222,11 +222,71 @@ export function DynamicPricingBreakdown({
       </div>
 
       {hasTiers && (
-        <div className='mb-4'>
+        <div className='mb-3 sm:mb-4'>
           <div className='text-foreground mb-2 text-sm font-semibold'>
             {t('Tiered price table')}
           </div>
-          <div className='-mx-4 max-w-[calc(100%+2rem)] overflow-x-auto sm:mx-0 sm:max-w-full'>
+          <div className='space-y-1.5 sm:hidden'>
+            {tiers.map((tier, i) => {
+              const condSummary = formatConditionSummary(tier.conditions, t)
+              const isMatched =
+                matchedTierLabel != null &&
+                matchedTierLabel !== '' &&
+                tier.label === matchedTierLabel
+              return (
+                <div
+                  key={`tier-mobile-${i}`}
+                  className={cn(
+                    'rounded-md border p-2',
+                    isMatched &&
+                      'border-emerald-500/40 bg-emerald-500/10'
+                  )}
+                >
+                  <div className='mb-1.5 flex flex-wrap items-center gap-1.5'>
+                    <Badge
+                      variant='secondary'
+                      className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
+                    >
+                      {tier.label || t('Default')}
+                    </Badge>
+                    {isMatched && (
+                      <Badge
+                        variant='secondary'
+                        className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
+                      >
+                        {t('Matched')}
+                      </Badge>
+                    )}
+                  </div>
+                  {condSummary && (
+                    <div className='text-muted-foreground mb-1.5 text-xs'>
+                      {condSummary}
+                    </div>
+                  )}
+                  <div className='grid grid-cols-2 gap-x-3 gap-y-1.5'>
+                    {visiblePriceFields.map((v) => {
+                      const value = Number(
+                        tier[v.field as string as keyof ParsedTier] || 0
+                      )
+                      return (
+                        <div key={v.field} className='min-w-0'>
+                          <div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
+                            {t(v.shortLabel)}
+                          </div>
+                          <div className='truncate font-mono text-sm font-semibold'>
+                            {value > 0
+                              ? `${symbol}${(value * rate).toFixed(4)}`
+                              : '-'}
+                          </div>
+                        </div>
+                      )
+                    })}
+                  </div>
+                </div>
+              )
+            })}
+          </div>
+          <div className='hidden overflow-x-auto sm:block'>
             <Table className='text-sm'>
               <TableHeader>
                 <TableRow className='hover:bg-transparent'>

+ 6 - 6
web/default/src/features/pricing/components/filter-bar.tsx

@@ -620,16 +620,16 @@ export function FilterBar(props: FilterBarProps) {
       <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
         <SheetContent
           side='right'
-          className='flex w-full flex-col overflow-hidden p-0 sm:max-w-md'
+          className='flex h-dvh w-full flex-col overflow-hidden p-0 sm:max-w-md'
         >
-          <SheetHeader className='border-b px-6 py-4'>
+          <SheetHeader className='border-b px-4 py-3 sm:px-6 sm:py-4'>
             <SheetTitle>{t('Filters')}</SheetTitle>
             <SheetDescription className='sr-only'>
               {t('Filter models by type, endpoint, vendor, group and tags')}
             </SheetDescription>
           </SheetHeader>
 
-          <div className='flex-1 space-y-6 overflow-y-auto px-6 py-5'>
+          <div className='flex-1 space-y-4 overflow-y-auto px-4 py-4 sm:space-y-6 sm:px-6 sm:py-5'>
             <MobileFilterGroup
               title={t('Pricing Type')}
               value={props.quotaTypeFilter}
@@ -671,7 +671,7 @@ export function FilterBar(props: FilterBarProps) {
               <h3 className='text-foreground mb-3 text-sm font-semibold'>
                 {t('Display Options')}
               </h3>
-              <div className='space-y-4'>
+              <div className='space-y-3 sm:space-y-4'>
                 <div className='space-y-2'>
                   <p className='text-muted-foreground text-xs'>
                     {t('Price display')}
@@ -704,8 +704,8 @@ export function FilterBar(props: FilterBarProps) {
             </div>
           </div>
 
-          <SheetFooter className='border-t px-6 py-4'>
-            <div className='flex w-full gap-3'>
+          <SheetFooter className='border-t px-4 py-3 sm:px-6 sm:py-4'>
+            <div className='grid w-full grid-cols-2 gap-2 sm:flex sm:gap-3'>
               {props.hasActiveFilters && (
                 <Button
                   variant='outline'

+ 3 - 3
web/default/src/features/pricing/components/model-card.tsx

@@ -68,7 +68,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
   return (
     <div
       className={cn(
-        'group flex flex-col rounded-xl border p-4 transition-colors sm:p-5',
+        'group flex flex-col rounded-xl border p-3 transition-colors sm:p-5',
         'hover:bg-muted/20'
       )}
     >
@@ -175,12 +175,12 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
       </div>
 
       {/* Description */}
-      <p className='text-muted-foreground mt-3 line-clamp-1 flex-1 text-[13px] leading-relaxed sm:mt-4 sm:line-clamp-2 sm:min-h-[2.5rem]'>
+      <p className='text-muted-foreground mt-2 line-clamp-1 flex-1 text-[13px] leading-relaxed sm:mt-4 sm:line-clamp-2 sm:min-h-[2.5rem]'>
         {props.model.description || t('No description available.')}
       </p>
 
       {/* Footer row 1: group + billing type */}
-      <div className='mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 sm:mt-4'>
+      <div className='mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 sm:mt-4'>
         {primaryGroup && (
           <span className='text-muted-foreground text-xs font-medium'>
             {primaryGroup} {t('Groups')}

+ 2 - 2
web/default/src/features/pricing/components/model-details.tsx

@@ -783,13 +783,13 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
     <Sheet open={open} onOpenChange={onOpenChange}>
       <SheetContent
         side='right'
-        className='flex w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
+        className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl xl:max-w-3xl'
       >
         <SheetHeader className='sr-only'>
           <SheetTitle>{props.model.model_name}</SheetTitle>
           <SheetDescription>{t('Model details')}</SheetDescription>
         </SheetHeader>
-        <div className='flex-1 overflow-y-auto px-5 pt-12 pb-6 sm:px-6'>
+        <div className='flex-1 overflow-y-auto px-4 pt-11 pb-5 sm:px-6 sm:pt-12 sm:pb-6'>
           <ModelDetailsContent {...contentProps} />
         </div>
       </SheetContent>

+ 3 - 3
web/default/src/features/pricing/components/pricing-toolbar.tsx

@@ -259,15 +259,15 @@ export function PricingToolbar(props: PricingToolbarProps) {
       <Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
         <SheetContent
           side='right'
-          className='flex w-full flex-col overflow-hidden p-0 sm:max-w-md'
+          className='flex h-dvh w-full flex-col overflow-hidden p-0 sm:max-w-md'
         >
-          <SheetHeader className='border-b px-6 py-4'>
+          <SheetHeader className='border-b px-4 py-3 sm:px-6 sm:py-4'>
             <SheetTitle>{t('Filter')}</SheetTitle>
             <SheetDescription>
               {t('Filter models by provider, group, type, endpoint, and tags.')}
             </SheetDescription>
           </SheetHeader>
-          <div className='flex-1 overflow-y-auto p-4'>
+          <div className='flex-1 overflow-y-auto p-3 sm:p-4'>
             <PricingSidebar
               quotaTypeFilter={props.quotaTypeFilter}
               endpointTypeFilter={props.endpointTypeFilter}

+ 5 - 5
web/default/src/features/pricing/index.tsx

@@ -129,7 +129,7 @@ export function Pricing() {
   if (isLoading) {
     return (
       <PublicLayout showMainContainer={false}>
-        <div className='mx-auto w-full max-w-[1800px] px-4 pt-20 pb-10 sm:px-6 xl:px-8'>
+        <div className='mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
           <LoadingSkeleton viewMode={viewMode} />
         </div>
       </PublicLayout>
@@ -152,15 +152,15 @@ export function Pricing() {
             WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
           }}
         />
-        <PageTransition className='relative mx-auto w-full max-w-[1800px] px-4 pt-20 pb-10 sm:px-6 xl:px-8'>
-          <header className='mx-auto mb-8 max-w-3xl pt-8 text-center sm:mb-10 sm:pt-10'>
+        <PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
+          <header className='mx-auto mb-5 max-w-3xl pt-5 text-center sm:mb-10 sm:pt-10'>
             <p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
               {t('Models Directory')}
             </p>
             <h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
               {t('Model Square')}
             </h1>
-            <p className='text-muted-foreground/80 mt-4 text-sm sm:text-base'>
+            <p className='text-muted-foreground/80 mt-3 text-sm sm:mt-4 sm:text-base'>
               {t('This site currently has {{count}} models enabled', {
                 count: models?.length || 0,
               })}
@@ -175,7 +175,7 @@ export function Pricing() {
               onChange={setSearchInput}
               onClear={clearSearch}
               placeholder={t('Search model name, provider, endpoint, or tag...')}
-              className='mx-auto mt-6 max-w-2xl'
+              className='mx-auto mt-4 max-w-2xl sm:mt-6'
             />
           </header>
 

+ 8 - 0
web/default/src/features/profile/api.ts

@@ -41,6 +41,14 @@ export async function updateUserSettings(
   return res.data
 }
 
+/**
+ * Update interface language preference
+ */
+export async function updateUserLanguage(language: string): Promise<ApiResponse> {
+  const res = await api.put('/api/user/self', { language })
+  return res.data
+}
+
 /**
  * Delete user account
  */

+ 136 - 0
web/default/src/features/profile/components/language-preferences-card.tsx

@@ -0,0 +1,136 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Languages, Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { useAuthStore } from '@/stores/auth-store'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { TitledCard } from '@/components/ui/titled-card'
+import { updateUserLanguage } from '../api'
+import { parseUserSettings } from '../lib'
+import type { UserProfile } from '../types'
+
+const LANGUAGE_OPTIONS = [
+  { value: 'zh', label: '简体中文' },
+  { value: 'en', label: 'English' },
+  { value: 'fr', label: 'Français' },
+  { value: 'ru', label: 'Русский' },
+  { value: 'ja', label: '日本語' },
+  { value: 'vi', label: 'Tiếng Việt' },
+] as const
+
+function normalizeLanguage(value?: string | null): string {
+  if (!value) return 'en'
+  const normalized = value.trim().replace(/_/g, '-').toLowerCase()
+  if (normalized.startsWith('zh')) return 'zh'
+  return LANGUAGE_OPTIONS.some((lang) => lang.value === normalized)
+    ? normalized
+    : 'en'
+}
+
+type LanguagePreferencesCardProps = {
+  profile: UserProfile | null
+  onProfileUpdate: () => void
+}
+
+export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
+  const { t, i18n } = useTranslation()
+  const { auth } = useAuthStore()
+  const [saving, setSaving] = useState(false)
+
+  const savedLanguage = useMemo(() => {
+    const settings = parseUserSettings(props.profile?.setting)
+    return normalizeLanguage(settings.language || i18n.language)
+  }, [props.profile?.setting, i18n.language])
+
+  const [currentLanguage, setCurrentLanguage] = useState(savedLanguage)
+
+  useEffect(() => {
+    setCurrentLanguage(savedLanguage)
+  }, [savedLanguage])
+
+  const handleLanguageChange = async (language: string) => {
+    const nextLanguage = normalizeLanguage(language)
+    if (nextLanguage === currentLanguage) return
+
+    const previousLanguage = currentLanguage
+    setCurrentLanguage(nextLanguage)
+    setSaving(true)
+    await i18n.changeLanguage(nextLanguage)
+
+    try {
+      const response = await updateUserLanguage(nextLanguage)
+      if (!response.success) {
+        throw new Error(response.message || t('Failed to update settings'))
+      }
+
+      if (auth.user) {
+        const existingSetting =
+          typeof auth.user.setting === 'string'
+            ? parseUserSettings(auth.user.setting)
+            : (auth.user.setting ?? {})
+        auth.setUser({
+          ...auth.user,
+          setting: JSON.stringify({
+            ...existingSetting,
+            language: nextLanguage,
+          }),
+        })
+      }
+
+      props.onProfileUpdate()
+      toast.success(t('Language preference saved'))
+    } catch (_error) {
+      setCurrentLanguage(previousLanguage)
+      await i18n.changeLanguage(previousLanguage)
+      toast.error(t('Failed to update settings'))
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  return (
+    <TitledCard
+      title={t('Language Preferences')}
+      description={t('Set the language used across the interface')}
+      icon={<Languages className='h-4 w-4' />}
+    >
+        <div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
+          <div className='space-y-1'>
+            <div className='text-sm font-medium'>{t('Interface Language')}</div>
+            <p className='text-muted-foreground line-clamp-2 text-xs sm:text-sm'>
+              {t(
+                'Language preferences sync across your signed-in devices and affect API error messages.'
+              )}
+            </p>
+          </div>
+          <div className='flex items-center gap-2 sm:min-w-48'>
+            <Select
+              value={currentLanguage}
+              onValueChange={handleLanguageChange}
+              disabled={saving}
+            >
+              <SelectTrigger className='w-full sm:w-48'>
+                <SelectValue placeholder={t('Select language')} />
+              </SelectTrigger>
+              <SelectContent>
+                {LANGUAGE_OPTIONS.map((language) => (
+                  <SelectItem key={language.value} value={language.value}>
+                    {language.label}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            {saving && (
+              <Loader2 className='text-muted-foreground size-4 animate-spin' />
+            )}
+          </div>
+        </div>
+    </TitledCard>
+  )
+}

+ 86 - 74
web/default/src/features/profile/components/passkey-card.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useMemo, useState } from 'react'
-import { KeyRound, ShieldAlert, Loader2 } from 'lucide-react'
+import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { toast } from 'sonner'
 import dayjs from '@/lib/dayjs'
@@ -169,14 +169,13 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
 
   if (pageLoading || loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader>
+      <Card 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' />
         </CardHeader>
-        <CardContent className='space-y-4'>
-          <Skeleton className='h-12 w-full' />
-          <Skeleton className='h-12 w-full' />
+        <CardContent className='p-3 sm:p-5'>
+          <Skeleton className='h-20 w-full' />
         </CardContent>
       </Card>
     )
@@ -191,19 +190,20 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
 
   return (
     <>
-      <Card className='overflow-hidden'>
-        <CardHeader>
-          <CardTitle className='text-xl tracking-tight'>
+      <Card 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')}
           </CardTitle>
-          <CardDescription>
+          <CardDescription className='text-xs sm:text-sm'>
             {t('Use Passkey to sign in without entering your password.')}
           </CardDescription>
         </CardHeader>
 
-        <CardContent className='space-y-6'>
-          <div className='flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-stretch 2xl:flex-row 2xl:items-center'>
-            <div className='flex items-start gap-3'>
+        <CardContent className='p-3 sm:p-5'>
+          <div className='space-y-6'>
+            <div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
+              <div className='flex items-start gap-4'>
               <div className='bg-muted rounded-md p-2'>
                 <KeyRound className='h-5 w-5' />
               </div>
@@ -241,74 +241,86 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
                   {t('Last used:')} {formattedLastUsed}
                 </p>
               </div>
+              </div>
+
+              {!enabled && (
+                <Button
+                  className='w-full sm:w-auto xl:w-full 2xl:w-auto'
+                  onClick={handleRegister}
+                  disabled={!supported || registering}
+                >
+                  {registering && (
+                    <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                  )}
+                  {t('Enable Passkey')}
+                </Button>
+              )}
             </div>
 
-            {!enabled ? (
-              <Button
-                className='w-full sm:w-auto xl:w-full 2xl:w-auto'
-                onClick={handleRegister}
-                disabled={!supported || registering}
-              >
-                {registering && (
-                  <Loader2 className='mr-2 h-4 w-4 animate-spin' />
-                )}
-                {t('Register Passkey')}
-              </Button>
-            ) : (
-              <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
-                <AlertDialogTrigger asChild>
-                  <Button
-                    variant='outline'
-                    className='w-full sm:w-auto xl:w-full 2xl:w-auto'
-                    disabled={removing}
-                  >
-                    {t('Remove Passkey')}
-                  </Button>
-                </AlertDialogTrigger>
-                <AlertDialogContent>
-                  <AlertDialogHeader>
-                    <AlertDialogTitle>{t('Remove Passkey?')}</AlertDialogTitle>
-                    <AlertDialogDescription>
-                      {t(
-                        'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
-                      )}
-                    </AlertDialogDescription>
-                  </AlertDialogHeader>
-                  <AlertDialogFooter>
-                    <AlertDialogCancel disabled={removing}>
-                      {t('Cancel')}
-                    </AlertDialogCancel>
-                    <AlertDialogAction
-                      className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
+            {enabled && (
+              <div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
+                <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
+                  <AlertDialogTrigger asChild>
+                    <Button
+                      variant='destructive'
+                      className='flex-1'
                       disabled={removing}
-                      onClick={(event) => {
-                        event.preventDefault()
-                        handleRemove()
-                      }}
                     >
-                      {t('Remove')}
-                    </AlertDialogAction>
-                  </AlertDialogFooter>
-                </AlertDialogContent>
-              </AlertDialog>
+                      {removing ? (
+                        <Loader2 className='mr-2 h-4 w-4 animate-spin' />
+                      ) : (
+                        <AlertTriangle className='mr-2 h-4 w-4' />
+                      )}
+                      {t('Remove Passkey')}
+                    </Button>
+                  </AlertDialogTrigger>
+                  <AlertDialogContent>
+                    <AlertDialogHeader>
+                      <AlertDialogTitle>
+                        {t('Remove Passkey?')}
+                      </AlertDialogTitle>
+                      <AlertDialogDescription>
+                        {t(
+                          'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
+                        )}
+                      </AlertDialogDescription>
+                    </AlertDialogHeader>
+                    <AlertDialogFooter>
+                      <AlertDialogCancel disabled={removing}>
+                        {t('Cancel')}
+                      </AlertDialogCancel>
+                      <AlertDialogAction
+                        className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
+                        disabled={removing}
+                        onClick={(event) => {
+                          event.preventDefault()
+                          handleRemove()
+                        }}
+                      >
+                        {t('Remove')}
+                      </AlertDialogAction>
+                    </AlertDialogFooter>
+                  </AlertDialogContent>
+                </AlertDialog>
+              </div>
             )}
-          </div>
 
-          {showUnsupportedNotice && (
-            <div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
-              <ShieldAlert className='mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500' />
-              <div>
-                <p className='text-foreground font-medium'>
-                  {t('Passkey not supported on this device')}
-                </p>
-                <p>
-                  {t(
-                    'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
-                  )}
-                </p>
+            {showUnsupportedNotice && (
+              <div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
+                <ShieldAlert className='mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500' />
+                <div>
+                  <p className='text-foreground font-medium'>
+                    {t('Passkey not supported on this device')}
+                  </p>
+                  <p>
+                    {t(
+                      'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
+                    )}
+                  </p>
+                </div>
               </div>
-            </div>
-          )}
+            )}
+          </div>
         </CardContent>
       </Card>
 

+ 16 - 16
web/default/src/features/profile/components/profile-header.tsx

@@ -82,17 +82,17 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
 
   return (
     <div className='bg-card overflow-hidden rounded-lg border'>
-      <div className='p-4 sm:p-5'>
-        <div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
-          <Avatar className='ring-background h-16 w-16 rounded-2xl text-lg ring-4'>
-            <AvatarFallback className='bg-primary/10 text-primary rounded-2xl'>
+      <div 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>
           </Avatar>
 
-          <div className='min-w-0 flex-1 space-y-3'>
-            <div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-start'>
-              <h1 className='text-2xl font-semibold tracking-tight'>
+          <div className='min-w-0 flex-1 space-y-1.5 sm:space-y-3'>
+            <div className='flex min-w-0 items-center gap-2'>
+              <h1 className='truncate text-xl font-semibold tracking-tight sm:text-2xl'>
                 {displayName}
               </h1>
               <StatusBadge
@@ -102,18 +102,18 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
               />
             </div>
 
-            <div className='text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:flex-wrap sm:justify-start sm:gap-4'>
-              <span>@{profile.username}</span>
+            <div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:gap-x-4 sm:text-sm'>
+              <span className='truncate'>@{profile.username}</span>
               {profile.email && (
                 <>
-                  <span className='hidden sm:inline'>•</span>
-                  <span>{profile.email}</span>
+                  <span>•</span>
+                  <span className='truncate'>{profile.email}</span>
                 </>
               )}
               {profile.group && (
                 <>
-                  <span className='hidden sm:inline'>•</span>
-                  <span>{profile.group}</span>
+                  <span>•</span>
+                  <span className='truncate'>{profile.group}</span>
                 </>
               )}
             </div>
@@ -121,9 +121,9 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
         </div>
       </div>
       <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'>
+        <div className='divide-border/60 grid grid-cols-3 divide-x'>
           {stats.map((item) => (
-            <div key={item.label} className='px-4 py-3.5 sm:px-5 sm:py-4'>
+            <div key={item.label} className='min-w-0 px-3 py-3 sm:px-5 sm:py-4'>
               <div className='flex items-center gap-2'>
                 <item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
                 <div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
@@ -131,7 +131,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
                 </div>
               </div>
 
-              <div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight break-all tabular-nums'>
+              <div className='text-foreground mt-1.5 truncate font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
                 {item.value}
               </div>
               <div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>

+ 18 - 31
web/default/src/features/profile/components/profile-security-card.tsx

@@ -4,11 +4,10 @@ import { useDialogs } from '@/hooks/use-dialog'
 import {
   Card,
   CardContent,
-  CardDescription,
   CardHeader,
-  CardTitle,
 } from '@/components/ui/card'
 import { Skeleton } from '@/components/ui/skeleton'
+import { TitledCard } from '@/components/ui/titled-card'
 import type { UserProfile } from '../types'
 import { AccessTokenDialog } from './dialogs/access-token-dialog'
 import { ChangePasswordDialog } from './dialogs/change-password-dialog'
@@ -34,12 +33,12 @@ export function ProfileSecurityCard({
 
   if (loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
+      <Card 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' />
         </CardHeader>
-        <CardContent className='space-y-3 pt-6'>
+        <CardContent className='space-y-3 p-3 sm:p-5'>
           {Array.from({ length: 3 }).map((_, i) => (
             <Skeleton key={i} className='h-16 w-full' />
           ))}
@@ -76,31 +75,18 @@ export function ProfileSecurityCard({
 
   return (
     <>
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
-          <div className='flex items-center gap-3'>
-            <div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
-              <Shield className='h-4 w-4' />
-            </div>
-            <div className='min-w-0'>
-              <CardTitle className='text-xl tracking-tight'>
-                {t('Security')}
-              </CardTitle>
-              <CardDescription>
-                {t('Manage your security settings and account access')}
-              </CardDescription>
-            </div>
-          </div>
-        </CardHeader>
-
-        <CardContent className='pt-6'>
-          <div className='grid grid-cols-1 gap-3 md:grid-cols-3'>
+      <TitledCard
+        title={t('Security')}
+        description={t('Manage your security settings and account access')}
+        icon={<Shield className='h-4 w-4' />}
+      >
+          <div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
             {securityActions.map((item) => (
               <button
                 key={item.title}
                 type='button'
                 onClick={item.action}
-                className={`hover:bg-muted/50 flex flex-col items-center gap-2 rounded-lg border p-4 text-center transition-colors ${
+                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'
                     : ''
@@ -115,15 +101,16 @@ export function ProfileSecurityCard({
                 >
                   <item.icon className='h-5 w-5' />
                 </div>
-                <p className='text-sm font-medium'>{item.title}</p>
-                <p className='text-muted-foreground text-xs'>
-                  {item.description}
-                </p>
+                <div className='min-w-0 md:contents'>
+                  <p className='text-sm font-medium'>{item.title}</p>
+                  <p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
+                    {item.description}
+                  </p>
+                </div>
               </button>
             ))}
           </div>
-        </CardContent>
-      </Card>
+      </TitledCard>
 
       {/* Dialogs */}
       <ChangePasswordDialog

+ 14 - 29
web/default/src/features/profile/components/profile-settings-card.tsx

@@ -4,12 +4,11 @@ import { useTranslation } from 'react-i18next'
 import {
   Card,
   CardContent,
-  CardDescription,
   CardHeader,
-  CardTitle,
 } from '@/components/ui/card'
 import { Skeleton } from '@/components/ui/skeleton'
 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { TitledCard } from '@/components/ui/titled-card'
 import type { UserProfile } from '../types'
 import { AccountBindingsTab } from './tabs/account-bindings-tab'
 import { NotificationTab } from './tabs/notification-tab'
@@ -34,12 +33,12 @@ export function ProfileSettingsCard({
 
   if (loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
+      <Card 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' />
         </CardHeader>
-        <CardContent className='space-y-4 pt-6'>
+        <CardContent className='space-y-4 p-3 sm:p-5'>
           <Skeleton className='h-10 w-full' />
           {Array.from({ length: 3 }).map((_, i) => (
             <Skeleton key={i} className='h-20 w-full' />
@@ -50,29 +49,16 @@ export function ProfileSettingsCard({
   }
 
   return (
-    <Card className='overflow-hidden'>
-      <CardHeader className='border-b'>
-        <div className='flex items-center gap-3'>
-          <div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
-            <Settings className='h-4 w-4' />
-          </div>
-          <div className='min-w-0'>
-            <CardTitle className='text-xl tracking-tight'>
-              {t('Settings')}
-            </CardTitle>
-            <CardDescription>
-              {t('Configure your account preferences and integrations')}
-            </CardDescription>
-          </div>
-        </div>
-      </CardHeader>
-
-      <CardContent className='pt-6'>
+    <TitledCard
+      title={t('Settings')}
+      description={t('Configure your account preferences and integrations')}
+      icon={<Settings className='h-4 w-4' />}
+    >
         <Tabs value={activeTab} onValueChange={setActiveTab}>
           <TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
             <TabsTrigger
               value='bindings'
-              className='h-auto gap-2 rounded-lg px-3 py-2.5'
+              className='h-auto gap-2 rounded-lg px-3 py-2'
             >
               <Link2 className='h-4 w-4' />
               <span className='hidden sm:inline'>{t('Account Bindings')}</span>
@@ -80,7 +66,7 @@ export function ProfileSettingsCard({
             </TabsTrigger>
             <TabsTrigger
               value='settings'
-              className='h-auto gap-2 rounded-lg px-3 py-2.5'
+              className='h-auto gap-2 rounded-lg px-3 py-2'
             >
               <Settings className='h-4 w-4' />
               <span className='hidden sm:inline'>
@@ -90,15 +76,14 @@ export function ProfileSettingsCard({
             </TabsTrigger>
           </TabsList>
 
-          <TabsContent value='bindings' className='mt-6'>
+          <TabsContent value='bindings' className='mt-4 sm:mt-6'>
             <AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
           </TabsContent>
 
-          <TabsContent value='settings' className='mt-6'>
+          <TabsContent value='settings' className='mt-4 sm:mt-6'>
             <NotificationTab profile={profile} onUpdate={onProfileUpdate} />
           </TabsContent>
         </Tabs>
-      </CardContent>
-    </Card>
+    </TitledCard>
   )
 }

+ 6 - 6
web/default/src/features/profile/components/sidebar-modules-card.tsx

@@ -182,23 +182,23 @@ export function SidebarModulesCard() {
   }
 
   return (
-    <Card className='overflow-hidden'>
-      <CardHeader className='border-b'>
+    <Card 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-9 w-9 shrink-0 items-center justify-center rounded-lg'>
+          <div className='bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9'>
             <LayoutDashboard className='h-4 w-4' />
           </div>
           <div className='min-w-0'>
-            <CardTitle className='text-xl tracking-tight'>
+            <CardTitle className='text-lg tracking-tight sm:text-xl'>
               {t('Sidebar Personal Settings')}
             </CardTitle>
-            <CardDescription>
+            <CardDescription className='text-xs sm:text-sm'>
               {t('Customize sidebar display content')}
             </CardDescription>
           </div>
         </div>
       </CardHeader>
-      <CardContent className='space-y-5 pt-6'>
+      <CardContent className='space-y-4 p-3 sm:space-y-5 sm:p-5'>
         {sectionDefs.map((section) => {
           const sectionEnabled = config[section.key]?.enabled !== false
           return (

+ 11 - 11
web/default/src/features/profile/components/tabs/account-bindings-tab.tsx

@@ -245,14 +245,14 @@ export function AccountBindingsTab({
 
   return (
     <>
-      <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
+      <div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
         {bindings.map((binding) => (
           <div
             key={binding.id}
-            className='flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'
+            className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3'
           >
-            <div className='flex min-w-0 items-center gap-3'>
-              <div className='bg-muted shrink-0 rounded-md p-2'>
+            <div className='flex min-w-0 items-center gap-2.5 sm:gap-3'>
+              <div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
                 <binding.icon className='h-4 w-4' />
               </div>
               <div className='min-w-0'>
@@ -274,7 +274,7 @@ export function AccountBindingsTab({
             <Button
               variant='outline'
               size='sm'
-              className='h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
+              className='h-7 shrink-0 px-2.5 text-xs'
               onClick={binding.onBind}
               disabled={binding.isBound && binding.id !== 'email'}
             >
@@ -295,7 +295,7 @@ export function AccountBindingsTab({
           <p className='text-muted-foreground mb-3 text-sm font-medium'>
             {t('Custom OAuth')}
           </p>
-          <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
+          <div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
             {customProviders.map((provider) => {
               const binding = customBindings.find(
                 (b) => b.provider_id === provider.id
@@ -304,10 +304,10 @@ export function AccountBindingsTab({
               return (
                 <div
                   key={provider.id}
-                  className='flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'
+                  className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3'
                 >
-                  <div className='flex min-w-0 items-center gap-3'>
-                    <div className='bg-muted shrink-0 rounded-md p-2'>
+                  <div className='flex min-w-0 items-center gap-2.5 sm:gap-3'>
+                    <div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
                       <Link2 className='h-4 w-4' />
                     </div>
                     <div className='min-w-0'>
@@ -332,7 +332,7 @@ export function AccountBindingsTab({
                     <Button
                       variant='ghost'
                       size='sm'
-                      className='text-destructive hover:text-destructive h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
+                      className='text-destructive hover:text-destructive h-7 shrink-0 px-2.5 text-xs'
                       onClick={() => setUnbindTarget(binding)}
                     >
                       <Unlink className='mr-1 h-3 w-3' />
@@ -342,7 +342,7 @@ export function AccountBindingsTab({
                     <Button
                       variant='outline'
                       size='sm'
-                      className='h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
+                      className='h-7 shrink-0 px-2.5 text-xs'
                       onClick={() => handleBindCustomOAuth(provider)}
                     >
                       {t('Bind')}

+ 31 - 23
web/default/src/features/profile/components/tabs/notification-tab.tsx

@@ -102,16 +102,16 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
   }
 
   return (
-    <div className='space-y-6'>
+    <div className='space-y-4 sm:space-y-6'>
       {/* Notification Type */}
-      <div className='space-y-3'>
+      <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-2 gap-3 sm:grid-cols-4'
+          className='grid grid-cols-4 gap-1.5 sm:gap-3'
         >
           {NOTIFICATION_METHODS.map((method) => {
             const Icon = NOTIFICATION_ICONS[method.value]
@@ -120,7 +120,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
               <Label
                 key={method.value}
                 htmlFor={method.value}
-                className={`flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 p-3 transition-colors ${
+                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'
@@ -131,8 +131,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
                   id={method.value}
                   className='sr-only'
                 />
-                <Icon className='h-5 w-5' />
-                <span className='text-sm font-medium'>{t(method.label)}</span>
+                <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>
             )
           })}
@@ -140,11 +142,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
       </div>
 
       {/* Warning Threshold */}
-      <div className='space-y-2'>
+      <div className='space-y-1.5'>
         <Label htmlFor='threshold'>{t('Quota Warning Threshold')}</Label>
         <Input
           id='threshold'
           type='number'
+          className='h-9'
           value={settings.quota_warning_threshold}
           onChange={(e) =>
             updateField('quota_warning_threshold', Number(e.target.value))
@@ -158,11 +161,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
 
       {/* Email Settings */}
       {settings.notify_type === 'email' && (
-        <div className='space-y-2'>
+        <div className='space-y-1.5'>
           <Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
           <Input
             id='notifyEmail'
             type='email'
+            className='h-9'
             value={settings.notification_email}
             onChange={(e) => updateField('notification_email', e.target.value)}
             placeholder={t('Leave empty to use account email')}
@@ -173,17 +177,18 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
       {/* Webhook Settings */}
       {settings.notify_type === 'webhook' && (
         <>
-          <div className='space-y-2'>
+          <div className='space-y-1.5'>
             <Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
             <Input
               id='webhookUrl'
               type='url'
+              className='h-9'
               value={settings.webhook_url}
               onChange={(e) => updateField('webhook_url', e.target.value)}
               placeholder={t('https://example.com/webhook')}
             />
           </div>
-          <div className='space-y-2'>
+          <div className='space-y-1.5'>
             <Label htmlFor='webhookSecret'>{t('Webhook Secret')}</Label>
             <PasswordInput
               id='webhookSecret'
@@ -197,11 +202,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
 
       {/* Bark Settings */}
       {settings.notify_type === 'bark' && (
-        <div className='space-y-2'>
+        <div className='space-y-1.5'>
           <Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
           <Input
             id='barkUrl'
             type='url'
+            className='h-9'
             value={settings.bark_url}
             onChange={(e) => updateField('bark_url', e.target.value)}
             placeholder={t('https://api.day.app/yourkey/{{title}}/{{content}}')}
@@ -215,11 +221,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
       {/* Gotify Settings */}
       {settings.notify_type === 'gotify' && (
         <>
-          <div className='space-y-2'>
+          <div className='space-y-1.5'>
             <Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
             <Input
               id='gotifyUrl'
               type='url'
+              className='h-9'
               value={settings.gotify_url}
               onChange={(e) => updateField('gotify_url', e.target.value)}
               placeholder={t('https://gotify.example.com')}
@@ -228,7 +235,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
               {t('Enter the full URL of your Gotify server')}
             </p>
           </div>
-          <div className='space-y-2'>
+          <div className='space-y-1.5'>
             <Label htmlFor='gotifyToken'>{t('Gotify Application Token')}</Label>
             <PasswordInput
               id='gotifyToken'
@@ -240,11 +247,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
               {t('Token obtained from your Gotify application')}
             </p>
           </div>
-          <div className='space-y-2'>
+          <div className='space-y-1.5'>
             <Label htmlFor='gotifyPriority'>{t('Message Priority')}</Label>
             <Input
               id='gotifyPriority'
               type='number'
+              className='h-9'
               min='0'
               max='10'
               value={settings.gotify_priority}
@@ -259,8 +267,8 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
               )}
             </p>
           </div>
-          <div className='bg-muted/50 rounded-lg border p-4'>
-            <h5 className='mb-2 text-sm font-medium'>
+          <div className='bg-muted/50 rounded-lg border p-3 sm:p-4'>
+            <h5 className='mb-1.5 text-sm font-medium sm:mb-2'>
               {t('Setup Instructions')}
             </h5>
             <ol className='text-muted-foreground space-y-1 text-xs'>
@@ -287,7 +295,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
       <div className='border-t' />
 
       {/* Preferences Section */}
-      <div className='space-y-4'>
+      <div className='space-y-3'>
         <div>
           <h4 className='text-sm font-medium'>{t('Preferences')}</h4>
           <p className='text-muted-foreground mt-1 text-xs'>
@@ -297,12 +305,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
 
         {/* Receive Upstream Model Update Notifications (admin only) */}
         {isAdmin && (
-          <div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
+          <div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
             <div className='space-y-0.5'>
               <Label htmlFor='upstreamModelUpdateNotify'>
                 {t('Receive Upstream Model Update Notifications')}
               </Label>
-              <p className='text-muted-foreground text-sm'>
+              <p className='text-muted-foreground line-clamp-3 text-xs sm:line-clamp-none sm:text-sm'>
                 {t(
                   'Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.'
                 )}
@@ -320,12 +328,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
         )}
 
         {/* Accept Unset Model Price */}
-        <div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
+        <div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
           <div className='space-y-0.5'>
             <Label htmlFor='acceptUnsetPrice'>
               {t('Accept Unpriced Models')}
             </Label>
-            <p className='text-muted-foreground text-sm'>
+            <p className='text-muted-foreground text-xs sm:text-sm'>
               {t('Allow using models without price configuration')}
             </p>
           </div>
@@ -340,10 +348,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
         </div>
 
         {/* Record IP Log */}
-        <div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
+        <div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
           <div className='space-y-0.5'>
             <Label htmlFor='recordIp'>{t('Record IP Address')}</Label>
-            <p className='text-muted-foreground text-sm'>
+            <p className='text-muted-foreground text-xs sm:text-sm'>
               {t('Log IP address for usage and error logs')}
             </p>
           </div>

+ 8 - 8
web/default/src/features/profile/components/two-fa-card.tsx

@@ -33,12 +33,12 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
 
   if (pageLoading || loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader>
+      <Card 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' />
         </CardHeader>
-        <CardContent>
+        <CardContent className='p-3 sm:p-5'>
           <Skeleton className='h-20 w-full' />
         </CardContent>
       </Card>
@@ -47,17 +47,17 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
 
   return (
     <>
-      <Card className='overflow-hidden'>
-        <CardHeader>
-          <CardTitle className='text-xl tracking-tight'>
+      <Card 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')}
           </CardTitle>
-          <CardDescription>
+          <CardDescription className='text-xs sm:text-sm'>
             {t('Add an extra layer of security to your account')}
           </CardDescription>
         </CardHeader>
 
-        <CardContent>
+        <CardContent className='p-3 sm:p-5'>
           <div className='space-y-6'>
             {/* Status Section */}
             <div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>

+ 10 - 5
web/default/src/features/profile/index.tsx

@@ -6,6 +6,7 @@ import {
   CardStaggerItem,
 } from '@/components/page-transition'
 import { CheckinCalendarCard } from './components/checkin-calendar-card'
+import { LanguagePreferencesCard } from './components/language-preferences-card'
 import { PasskeyCard } from './components/passkey-card'
 import { ProfileHeader } from './components/profile-header'
 import { ProfileSecurityCard } from './components/profile-security-card'
@@ -30,24 +31,28 @@ export function Profile() {
     <>
       <AppHeader />
       <Main>
-        <div className='min-h-0 flex-1 overflow-auto px-4 py-4 sm:py-6'>
-          <CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-5 sm:gap-6'>
+        <div className='min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4 sm:py-6'>
+          <CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-6'>
             <CardStaggerItem>
               <ProfileHeader profile={profile} loading={loading} />
             </CardStaggerItem>
 
             <CardStaggerItem>
-              <div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
-                <div className='space-y-5 sm:space-y-6'>
+              <div className='grid gap-4 sm:gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
+                <div className='space-y-4 sm:space-y-6'>
                   <ProfileSettingsCard
                     profile={profile}
                     loading={loading}
                     onProfileUpdate={refreshProfile}
                   />
+                  <LanguagePreferencesCard
+                    profile={profile}
+                    onProfileUpdate={refreshProfile}
+                  />
                   <ProfileSecurityCard profile={profile} loading={loading} />
                 </div>
 
-                <div className='space-y-5 sm:space-y-6 xl:sticky xl:top-6'>
+                <div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
                   {checkinEnabled && (
                     <CheckinCalendarCard
                       checkinEnabled={checkinEnabled}

+ 2 - 0
web/default/src/features/profile/types.ts

@@ -98,6 +98,8 @@ export interface UserSettings {
   record_ip_log?: boolean
   /** Receive upstream model update notifications (admin only) */
   upstream_model_update_notify_enabled?: boolean
+  /** Preferred interface/API response language */
+  language?: string
 }
 
 /**

+ 5 - 5
web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx

@@ -133,8 +133,8 @@ export function RedemptionsMutateDrawer({
         }
       }}
     >
-      <SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
-        <SheetHeader className='text-start'>
+      <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
+        <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
           <SheetTitle>
             {isUpdate
               ? t('Update Redemption Code')
@@ -153,7 +153,7 @@ export function RedemptionsMutateDrawer({
           <form
             id='redemption-form'
             onSubmit={form.handleSubmit(onSubmit)}
-            className='flex-1 space-y-6 overflow-y-auto px-4'
+            className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
           >
             <FormField
               control={form.control}
@@ -215,7 +215,7 @@ export function RedemptionsMutateDrawer({
                         placeholder={t('Never expires')}
                       />
                     </FormControl>
-                    <div className='flex gap-2'>
+                    <div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
                       <Button
                         type='button'
                         variant='outline'
@@ -287,7 +287,7 @@ export function RedemptionsMutateDrawer({
             )}
           </form>
         </Form>
-        <SheetFooter className='gap-2'>
+        <SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
           <SheetClose asChild>
             <Button variant='outline'>{t('Close')}</Button>
           </SheetClose>

+ 2 - 2
web/default/src/features/redemption-codes/components/redemptions-table.tsx

@@ -72,7 +72,7 @@ export function RedemptionsTable() {
   } = useTableUrlState({
     search: route.useSearch(),
     navigate: route.useNavigate(),
-    pagination: { defaultPage: 1, defaultPageSize: 20 },
+    pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
     globalFilter: { enabled: true, key: 'filter' },
     columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }],
   })
@@ -154,7 +154,7 @@ export function RedemptionsTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         <DataTableToolbar
           table={table}
           searchPlaceholder={t('Filter by name or ID...')}

+ 5 - 5
web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx

@@ -165,7 +165,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
 
   return (
     <Dialog open={props.open} onOpenChange={props.onOpenChange}>
-      <DialogContent className='sm:max-w-md'>
+      <DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
         <DialogHeader>
           <DialogTitle className='flex items-center gap-2'>
             <Crown className='h-5 w-5' />
@@ -173,8 +173,8 @@ export function SubscriptionPurchaseDialog(props: Props) {
           </DialogTitle>
         </DialogHeader>
 
-        <div className='space-y-4'>
-          <div className='bg-muted/50 space-y-3 rounded-lg border p-4'>
+        <div className='space-y-3 sm:space-y-4'>
+          <div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
             <div className='flex justify-between'>
               <span className='text-muted-foreground text-sm'>
                 {t('Plan Name')}
@@ -239,7 +239,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
                 {t('Select payment method')}
               </p>
               {(hasStripe || hasCreem) && (
-                <div className='flex gap-2'>
+                <div className='grid grid-cols-2 gap-2 sm:flex'>
                   {hasStripe && (
                     <Button
                       variant='outline'
@@ -263,7 +263,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
                 </div>
               )}
               {hasEpay && (
-                <div className='flex gap-2'>
+                <div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
                   <Select
                     value={selectedEpayMethod}
                     onValueChange={setSelectedEpayMethod}

+ 9 - 9
web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx

@@ -124,8 +124,8 @@ export function SubscriptionsMutateDrawer({
         }
       }}
     >
-      <SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
-        <SheetHeader className='text-start'>
+      <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
+        <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
           <SheetTitle>
             {isEdit ? t('Update plan info') : t('Create new subscription plan')}
           </SheetTitle>
@@ -141,7 +141,7 @@ export function SubscriptionsMutateDrawer({
           <form
             id='subscription-form'
             onSubmit={form.handleSubmit(onSubmit)}
-            className='flex-1 space-y-6 overflow-y-auto px-4'
+            className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
           >
             {/* Basic Info */}
             <div className='space-y-4'>
@@ -181,7 +181,7 @@ export function SubscriptionsMutateDrawer({
                 )}
               />
 
-              <div className='grid grid-cols-2 gap-3'>
+              <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
                 <FormField
                   control={form.control}
                   name='price_amount'
@@ -229,7 +229,7 @@ export function SubscriptionsMutateDrawer({
                 />
               </div>
 
-              <div className='grid grid-cols-2 gap-3'>
+              <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
                 <FormField
                   control={form.control}
                   name='upgrade_group'
@@ -288,7 +288,7 @@ export function SubscriptionsMutateDrawer({
                 />
               </div>
 
-              <div className='grid grid-cols-2 gap-3'>
+              <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
                 <FormField
                   control={form.control}
                   name='sort_order'
@@ -336,7 +336,7 @@ export function SubscriptionsMutateDrawer({
                 {t('Duration Settings')}
               </h3>
 
-              <div className='grid grid-cols-2 gap-3'>
+              <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
                 <FormField
                   control={form.control}
                   name='duration_unit'
@@ -418,7 +418,7 @@ export function SubscriptionsMutateDrawer({
                 {t('Quota Reset')}
               </h3>
 
-              <div className='grid grid-cols-2 gap-3'>
+              <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
                 <FormField
                   control={form.control}
                   name='quota_reset_period'
@@ -508,7 +508,7 @@ export function SubscriptionsMutateDrawer({
             </div>
           </form>
         </Form>
-        <SheetFooter className='gap-2'>
+        <SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
           <SheetClose asChild>
             <Button variant='outline'>{t('Close')}</Button>
           </SheetClose>

+ 1 - 1
web/default/src/features/subscriptions/components/subscriptions-table.tsx

@@ -62,7 +62,7 @@ export function SubscriptionsTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         {isMobile ? (
           <MobileCardList
             table={table}

+ 1 - 1
web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx

@@ -769,7 +769,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
           </>
         )
       },
-      meta: { label: t('Details'), mobileHidden: true },
+      meta: { label: t('Details') },
       size: 180,
       maxSize: 200,
     }

+ 7 - 7
web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx

@@ -141,9 +141,9 @@ export function CommonLogsFilterBar({
     !!filters.requestId
 
   return (
-    <div className='space-y-3'>
+    <div className='space-y-2 sm:space-y-3'>
       {/* Primary filter row */}
-      <div className='grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-[minmax(280px,2fr)_minmax(140px,1fr)_minmax(120px,1fr)_minmax(120px,0.8fr)_auto]'>
+      <div className='grid grid-cols-2 gap-1.5 sm:grid-cols-4 sm:gap-2 lg:grid-cols-[minmax(280px,2fr)_minmax(140px,1fr)_minmax(120px,1fr)_minmax(120px,0.8fr)_auto]'>
         <CompactDateTimeRangePicker
           start={filters.startTime}
           end={filters.endTime}
@@ -214,7 +214,7 @@ export function CommonLogsFilterBar({
         )}
       >
         <div className='min-h-0 overflow-hidden'>
-          <div className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
+          <div className='grid grid-cols-2 gap-1.5 sm:grid-cols-4 sm:gap-2'>
             <Input
               placeholder={t('Token Name')}
               type={sensitiveVisible ? 'text' : 'password'}
@@ -257,9 +257,12 @@ export function CommonLogsFilterBar({
       <div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
         <div className='flex min-w-0 flex-wrap items-center gap-2 sm:gap-3'>
           {stats && <div className='min-w-0'>{stats}</div>}
+        </div>
+
+        <div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
           <button
             type='button'
-            className='text-muted-foreground hover:text-foreground inline-flex h-6 items-center gap-1 rounded px-1 text-xs transition-colors'
+            className='text-muted-foreground hover:text-foreground inline-flex size-8 items-center justify-center rounded-md border transition-colors'
             title={sensitiveVisible ? t('Hide') : t('Show')}
             aria-label={sensitiveVisible ? t('Hide') : t('Show')}
             onClick={() => setSensitiveVisible(!sensitiveVisible)}
@@ -270,9 +273,6 @@ export function CommonLogsFilterBar({
               <EyeOff className='size-3.5' />
             )}
           </button>
-        </div>
-
-        <div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
           <Button
             variant='outline'
             size='sm'

+ 22 - 19
web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx

@@ -63,13 +63,13 @@ function DetailRow(props: {
   muted?: boolean
 }) {
   return (
-    <div className='flex items-start gap-3 text-sm'>
-      <span className='text-muted-foreground w-28 shrink-0 text-xs'>
+    <div className='grid min-w-0 grid-cols-[5.25rem_minmax(0,1fr)] gap-2 text-sm sm:grid-cols-[7rem_minmax(0,1fr)] sm:gap-3'>
+      <span className='text-muted-foreground min-w-0 text-xs'>
         {props.label}
       </span>
       <span
         className={cn(
-          'min-w-0 text-xs break-words',
+          'min-w-0 max-w-full text-xs break-all sm:break-words',
           props.mono && 'font-mono',
           props.muted && 'text-muted-foreground'
         )}
@@ -88,7 +88,7 @@ function DetailSection(props: {
 }) {
   const isDanger = props.variant === 'danger'
   return (
-    <div className='space-y-1.5'>
+    <div className='min-w-0 space-y-1.5'>
       <Label
         className={cn(
           'flex items-center gap-1.5 text-xs font-semibold',
@@ -100,7 +100,7 @@ function DetailSection(props: {
       </Label>
       <div
         className={cn(
-          'space-y-1.5 rounded-md border p-2.5',
+          'min-w-0 space-y-1 overflow-hidden rounded-md border p-2.5 max-sm:p-2',
           isDanger
             ? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/20'
             : 'bg-muted/30'
@@ -462,11 +462,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
     <Dialog open={props.open} onOpenChange={props.onOpenChange}>
       <DialogContent
         className={cn(
-          'min-w-0',
+          'min-w-0 overflow-hidden',
+          'max-sm:max-h-[calc(100dvh-1.5rem)] max-sm:w-[calc(100vw-1.5rem)] max-sm:max-w-[calc(100vw-1.5rem)] max-sm:p-4',
           isTieredBilling ? 'sm:max-w-4xl lg:max-w-5xl' : 'sm:max-w-lg'
         )}
       >
-        <DialogHeader>
+        <DialogHeader className='max-sm:gap-1'>
           <DialogTitle className='flex items-center gap-2 text-base'>
             {t('Log Details')}
             <StatusBadge
@@ -481,10 +482,10 @@ export function DetailsDialog(props: DetailsDialogProps) {
           </DialogDescription>
         </DialogHeader>
 
-        <ScrollArea className='max-h-[70vh] min-w-0 pr-4'>
-          <div className='min-w-0 space-y-3 py-1'>
+        <ScrollArea className='max-h-[70vh] min-w-0 overflow-hidden pr-2 max-sm:max-h-[calc(100dvh-7rem)] sm:pr-4'>
+          <div className='w-full min-w-0 max-w-full space-y-2.5 overflow-hidden py-1 sm:space-y-3'>
             {/* Overview section - key identifiers */}
-            <div className='space-y-1.5'>
+            <div className='min-w-0 space-y-1'>
               {props.log.request_id && (
                 <DetailRow
                   label={t('Request ID')}
@@ -587,7 +588,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
             {/* Request conversion (admin only, not for refund) */}
             {showConversion && (
               <DetailSection label={t('Request Conversion')}>
-                <div className='relative'>
+                <div className='relative min-w-0'>
                   <Button
                     variant='ghost'
                     size='sm'
@@ -602,7 +603,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
                       <Copy className='size-3' />
                     )}
                   </Button>
-                  <div className='space-y-1 pr-6'>
+                  <div className='min-w-0 space-y-1 pr-6'>
                     {other?.request_path && (
                       <DetailRow
                         label={t('Path')}
@@ -610,12 +611,14 @@ export function DetailsDialog(props: DetailsDialogProps) {
                         mono
                       />
                     )}
-                    <div className='flex items-center gap-1.5 text-xs'>
+                    <div className='flex min-w-0 items-center gap-1.5 text-xs'>
                       <Route
                         className='text-muted-foreground size-3'
                         aria-hidden='true'
                       />
-                      <span className='break-words'>{conversionLabel}</span>
+                      <span className='min-w-0 break-all sm:break-words'>
+                        {conversionLabel}
+                      </span>
                     </div>
                   </div>
                 </div>
@@ -825,7 +828,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
 
             {/* Tiered pricing breakdown (when billing_mode is tiered_expr) */}
             {isTieredBilling && other?.expr_b64 && (
-              <div className='bg-muted/30 min-w-0 rounded-md border px-3'>
+              <div className='bg-muted/30 min-w-0 overflow-hidden rounded-md border px-3 max-sm:px-2'>
                 <DynamicPricingBreakdown
                   billingExpr={decodeBillingExprB64(other.expr_b64)}
                   matchedTierLabel={other.matched_tier}
@@ -964,7 +967,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
                     return (
                       <div
                         key={idx}
-                        className='bg-background/60 flex items-start gap-2 rounded border p-2'
+                        className='bg-background/60 flex min-w-0 flex-col gap-1.5 rounded border p-2 sm:flex-row sm:items-start sm:gap-2'
                       >
                         <StatusBadge
                           variant='neutral'
@@ -972,7 +975,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
                           className='shrink-0 font-medium'
                           copyable={false}
                         />
-                        <span className='min-w-0 font-mono text-[11px] leading-relaxed break-words'>
+                        <span className='min-w-0 font-mono text-[11px] leading-relaxed break-all sm:break-words'>
                           {parsed.content}
                         </span>
                       </div>
@@ -985,7 +988,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
             {details && (
               <div className='space-y-1.5'>
                 <Label className='text-xs font-semibold'>{t('Content')}</Label>
-                <div className='bg-muted/30 relative rounded-md border p-2.5'>
+                <div className='bg-muted/30 relative min-w-0 overflow-hidden rounded-md border p-2.5'>
                   <Button
                     variant='ghost'
                     size='sm'
@@ -1000,7 +1003,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
                       <Copy className='size-3' />
                     )}
                   </Button>
-                  <p className='pr-6 text-xs leading-relaxed break-words whitespace-pre-wrap'>
+                  <p className='min-w-0 pr-6 text-xs leading-relaxed break-all whitespace-pre-wrap sm:break-words'>
                     {details}
                   </p>
                 </div>

+ 6 - 6
web/default/src/features/usage-logs/components/dialogs/usage-logs-filter-dialog.tsx

@@ -271,7 +271,7 @@ export function UsageLogsFilterDialog({
 
   return (
     <Dialog open={open} onOpenChange={setOpen}>
-      <DialogContent className='sm:max-w-lg'>
+      <DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
         <DialogHeader>
           <DialogTitle>
             {t('Filter')} {t(getLogCategoryLabel(logCategory))} {t('Logs')}
@@ -281,15 +281,15 @@ export function UsageLogsFilterDialog({
           </DialogDescription>
         </DialogHeader>
 
-        <ScrollArea className='max-h-[60vh] pr-4'>
-          <div className='grid gap-4 py-4'>
+        <ScrollArea className='min-h-0 flex-1 pr-3 sm:max-h-[60vh] sm:pr-4'>
+          <div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
             {/* Quick time range selection */}
             <div className='grid gap-2'>
               <Label className='flex items-center gap-2'>
                 <Calendar className='h-4 w-4' />
                 {t('Quick Range')}
               </Label>
-              <div className='flex gap-2'>
+              <div className='grid grid-cols-2 gap-2 sm:flex'>
                 {TIME_RANGE_PRESETS.map((range) => (
                   <Button
                     key={range.days}
@@ -314,7 +314,7 @@ export function UsageLogsFilterDialog({
             <SectionDivider label={t('Custom Time Range')} />
 
             {/* Custom time range */}
-            <div className='grid gap-4'>
+            <div className='grid gap-3 sm:gap-4'>
               <div className='grid gap-2'>
                 <Label htmlFor='start_time'>{t('Start Time')}</Label>
                 <DateTimePicker
@@ -355,7 +355,7 @@ export function UsageLogsFilterDialog({
           </div>
         </ScrollArea>
 
-        <DialogFooter>
+        <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
           <Button onClick={handleReset} variant='outline' type='button'>
             <RotateCcw className='mr-2 h-4 w-4' />
             {t('Reset')}

+ 3 - 3
web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx

@@ -138,8 +138,8 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) {
   )
 
   return (
-    <div className='space-y-3'>
-      <div className='grid grid-cols-2 gap-2 lg:grid-cols-[minmax(280px,2fr)_minmax(180px,1fr)_minmax(120px,0.8fr)_auto]'>
+    <div className='space-y-2 sm:space-y-3'>
+      <div className='grid grid-cols-2 gap-1.5 sm:gap-2 lg:grid-cols-[minmax(280px,2fr)_minmax(180px,1fr)_minmax(120px,0.8fr)_auto]'>
         <CompactDateTimeRangePicker
           start={filters.startTime}
           end={filters.endTime}
@@ -166,7 +166,7 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) {
             className='h-9'
           />
         )}
-        <div className='col-span-2 flex shrink-0 items-center justify-end gap-2 lg:col-span-1'>
+        <div className='col-span-2 flex shrink-0 items-center justify-end gap-1.5 sm:gap-2 lg:col-span-1'>
           <Button
             variant='outline'
             size='sm'

+ 4 - 4
web/default/src/features/usage-logs/components/usage-logs-table.tsx

@@ -67,7 +67,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
   } = useTableUrlState({
     search: route.useSearch(),
     navigate: route.useNavigate(),
-    pagination: { defaultPage: 1, defaultPageSize: 100 },
+    pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
     globalFilter: { enabled: false },
     columnFilters: [
       { columnId: 'created_at', searchKey: 'type', type: 'array' as const },
@@ -185,16 +185,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         {logCategory === 'common' ? (
-          <div className='rounded-md border bg-card/50 p-3 shadow-xs'>
+          <div className='rounded-md border bg-card/50 p-2 shadow-xs sm:p-3'>
             <CommonLogsFilterBar
               stats={<CommonLogsStats />}
               viewOptions={<DataTableViewOptions table={table} />}
             />
           </div>
         ) : (
-          <div className='rounded-md border bg-card/50 p-3 shadow-xs'>
+          <div className='rounded-md border bg-card/50 p-2 shadow-xs sm:p-3'>
             <TaskLogsFilterBar
               logCategory={logCategory}
               viewOptions={<DataTableViewOptions table={table} />}

+ 4 - 4
web/default/src/features/users/components/users-mutate-drawer.tsx

@@ -152,8 +152,8 @@ export function UsersMutateDrawer({
           }
         }}
       >
-        <SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
-          <SheetHeader className='text-start'>
+        <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
+          <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
             <SheetTitle>
               {isUpdate ? t('Update') : t('Create')} {t('User')}
             </SheetTitle>
@@ -167,7 +167,7 @@ export function UsersMutateDrawer({
             <form
               id='user-form'
               onSubmit={form.handleSubmit(onSubmit)}
-              className='flex-1 space-y-6 overflow-y-auto px-4'
+              className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
             >
               {/* Basic Information */}
               <div className='space-y-4'>
@@ -396,7 +396,7 @@ export function UsersMutateDrawer({
               )}
             </form>
           </Form>
-          <SheetFooter className='gap-2'>
+          <SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
             <SheetClose asChild>
               <Button variant='outline'>{t('Close')}</Button>
             </SheetClose>

+ 2 - 2
web/default/src/features/users/components/users-table.tsx

@@ -74,7 +74,7 @@ export function UsersTable() {
   } = useTableUrlState({
     search: route.useSearch(),
     navigate: route.useNavigate(),
-    pagination: { defaultPage: 1, defaultPageSize: 20 },
+    pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
     globalFilter: { enabled: true, key: 'filter' },
     columnFilters: [
       { columnId: 'status', searchKey: 'status', type: 'array' },
@@ -168,7 +168,7 @@ export function UsersTable() {
 
   return (
     <>
-      <div className='space-y-4'>
+      <div className='space-y-3 sm:space-y-4'>
         <DataTableToolbar
           table={table}
           searchPlaceholder={t('Filter by username, name or email...')}

+ 57 - 108
web/default/src/features/wallet/components/affiliate-rewards-card.tsx

@@ -2,15 +2,8 @@ import { Share2 } from 'lucide-react'
 import { useTranslation } from 'react-i18next'
 import { formatQuota } from '@/lib/format'
 import { Button } from '@/components/ui/button'
-import {
-  Card,
-  CardContent,
-  CardDescription,
-  CardHeader,
-  CardTitle,
-} from '@/components/ui/card'
+import { Card, CardContent } from '@/components/ui/card'
 import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
 import { Skeleton } from '@/components/ui/skeleton'
 import { CopyButton } from '@/components/copy-button'
 import type { UserWalletData } from '../types'
@@ -31,33 +24,14 @@ export function AffiliateRewardsCard({
   const { t } = useTranslation()
   if (loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
-          <Skeleton className='h-6 w-32' />
-          <Skeleton className='mt-2 h-4 w-48' />
-        </CardHeader>
-        <CardContent className='space-y-6 pt-6'>
-          {/* Statistics Skeleton */}
-          <div className='grid grid-cols-1 gap-3'>
-            {Array.from({ length: 3 }).map((_, i) => (
-              <div key={i} className='rounded-lg border p-3'>
-                <Skeleton className='h-3 w-16' />
-                <Skeleton className='mt-2 h-8 w-24' />
-              </div>
-            ))}
+      <Card 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' />
+            <Skeleton className='mt-2 h-4 w-48' />
           </div>
-
-          {/* Affiliate Link Skeleton */}
-          <div className='space-y-3'>
-            <Skeleton className='h-3 w-32' />
-            <div className='flex gap-2'>
-              <Skeleton className='h-10 flex-1' />
-              <Skeleton className='size-9' />
-            </div>
-          </div>
-
-          {/* Info Section Skeleton */}
-          <Skeleton className='h-20 w-full rounded-lg' />
+          <Skeleton className='h-14 rounded-lg' />
+          <Skeleton className='h-10 rounded-lg' />
         </CardContent>
       </Card>
     )
@@ -66,89 +40,64 @@ export function AffiliateRewardsCard({
   const hasRewards = (user?.aff_quota ?? 0) > 0
 
   return (
-    <Card className='overflow-hidden'>
-      <CardHeader className='border-b'>
-        <div className='flex items-center gap-3'>
-          <div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
-            <Share2 className='h-4 w-4' />
+    <Card 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'>
+            <Share2 className='text-muted-foreground size-4' />
           </div>
           <div className='min-w-0'>
-            <CardTitle className='text-xl tracking-tight'>
+            <h3 className='truncate text-sm font-semibold'>
               {t('Referral Program')}
-            </CardTitle>
-            <CardDescription>
-              {t('Share your link and earn rewards')}
-            </CardDescription>
+            </h3>
+            <p className='text-muted-foreground line-clamp-1 text-xs'>
+              {t(
+                'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.'
+              )}
+            </p>
           </div>
         </div>
-      </CardHeader>
-      <CardContent className='space-y-6 pt-6'>
-        {/* Statistics */}
-        <div className='grid grid-cols-1 gap-3 sm:grid-cols-3 xl:grid-cols-1'>
-          <div className='rounded-lg border p-3'>
-            <div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
-              {t('Pending')}
-            </div>
-            <div className='mt-2 text-2xl font-semibold break-all'>
-              {formatQuota(user?.aff_quota ?? 0)}
-            </div>
-          </div>
-
-          <div className='rounded-lg border p-3'>
-            <div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
-              {t('Total Earned')}
-            </div>
-            <div className='mt-2 text-2xl font-semibold break-all'>
-              {formatQuota(user?.aff_history_quota ?? 0)}
-            </div>
-          </div>
 
-          <div className='rounded-lg border p-3'>
-            <div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
-              {t('Invites')}
-            </div>
-            <div className='mt-2 text-2xl font-semibold'>
-              {user?.aff_count ?? 0}
+        <div className='grid grid-cols-3 gap-1.5 text-center'>
+          {[
+            [t('Pending'), formatQuota(user?.aff_quota ?? 0)],
+            [t('Total Earned'), formatQuota(user?.aff_history_quota ?? 0)],
+            [t('Invites'), String(user?.aff_count ?? 0)],
+          ].map(([label, value]) => (
+            <div key={label}>
+              <div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
+                {label}
+              </div>
+              <div className='mt-0.5 truncate text-sm font-semibold tabular-nums'>
+                {value}
+              </div>
             </div>
-          </div>
-        </div>
-
-        {/* Transfer Button */}
-        {hasRewards && (
-          <Button onClick={onTransfer} className='w-full' variant='default'>
-            {t('Transfer to Balance')}
-          </Button>
-        )}
-
-        {/* Affiliate Link */}
-        <div className='space-y-3'>
-          <Label className='text-muted-foreground text-xs tracking-wider uppercase'>
-            {t('Your Referral Link')}
-          </Label>
-          <div className='flex gap-2'>
-            <Input
-              value={affiliateLink}
-              readOnly
-              className='border-muted bg-muted/30 font-mono text-sm'
-            />
-            <CopyButton
-              value={affiliateLink}
-              variant='outline'
-              className='size-9'
-              iconClassName='size-4'
-              tooltip={t('Copy referral link')}
-              aria-label={t('Copy referral link')}
-            />
-          </div>
+          ))}
         </div>
 
-        {/* Info */}
-        <div className='bg-muted/30 space-y-2 rounded-lg p-4'>
-          <p className='text-muted-foreground text-sm leading-relaxed'>
-            {t(
-              'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.'
-            )}
-          </p>
+        <div className='flex items-center gap-2'>
+          <Input
+            value={affiliateLink}
+            readOnly
+            className='border-muted bg-background/70 h-9 min-w-0 flex-1 font-mono text-xs'
+          />
+          <CopyButton
+            value={affiliateLink}
+            variant='outline'
+            className='size-9 shrink-0 bg-background'
+            iconClassName='size-4'
+            tooltip={t('Copy referral link')}
+            aria-label={t('Copy referral link')}
+          />
+          {hasRewards && (
+            <Button
+              onClick={onTransfer}
+              className='h-9 shrink-0 px-3'
+              size='sm'
+            >
+              {t('Transfer to Balance')}
+            </Button>
+          )}
         </div>
       </CardContent>
     </Card>

+ 3 - 3
web/default/src/features/wallet/components/creem-products-section.tsx

@@ -20,7 +20,7 @@ export function CreemProductsSection({
 
   if (loading) {
     return (
-      <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3'>
+      <div className='grid grid-cols-2 gap-2 sm:grid-cols-2 sm:gap-3 md:grid-cols-3'>
         {Array.from({ length: 3 }).map((_, i) => (
           <Skeleton key={i} className='h-24 rounded-lg' />
         ))}
@@ -33,14 +33,14 @@ export function CreemProductsSection({
   }
 
   return (
-    <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3'>
+    <div className='grid grid-cols-2 gap-2 sm:grid-cols-2 sm:gap-3 md:grid-cols-3'>
       {products.map((product) => (
         <Card
           key={product.productId}
           className='hover:border-foreground/50 cursor-pointer transition-all hover:shadow-md'
           onClick={() => onProductSelect(product)}
         >
-          <CardContent className='p-4 text-center'>
+          <CardContent className='p-3 text-center sm:p-4'>
             <div className='mb-2 text-lg font-medium'>{product.name}</div>
             <div className='text-muted-foreground mb-2 text-sm'>
               {t('Quota')}: {formatNumber(product.quota)}

+ 13 - 13
web/default/src/features/wallet/components/dialogs/billing-history-dialog.tsx

@@ -83,7 +83,7 @@ export function BillingHistoryDialog({
   return (
     <>
       <Dialog open={open} onOpenChange={onOpenChange}>
-        <DialogContent className='max-w-4xl'>
+        <DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-4xl'>
           <DialogHeader>
             <DialogTitle>{t('Billing History')}</DialogTitle>
             <DialogDescription>
@@ -91,7 +91,7 @@ export function BillingHistoryDialog({
             </DialogDescription>
           </DialogHeader>
 
-          <div className='space-y-4'>
+          <div className='min-h-0 flex-1 space-y-3 sm:space-y-4'>
             {/* Search and Filter Bar */}
             <div className='flex items-center gap-2'>
               <div className='relative flex-1'>
@@ -100,14 +100,14 @@ export function BillingHistoryDialog({
                   placeholder={t('Search by order number...')}
                   value={keyword}
                   onChange={(e) => handleSearch(e.target.value)}
-                  className='pl-10'
+                  className='h-9 pl-10'
                 />
               </div>
               <Select
                 value={pageSize.toString()}
                 onValueChange={(value) => handlePageSizeChange(parseInt(value))}
               >
-                <SelectTrigger className='w-32'>
+                <SelectTrigger className='h-9 w-[92px] sm:w-32'>
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>
@@ -120,11 +120,11 @@ export function BillingHistoryDialog({
             </div>
 
             {/* Records List */}
-            <ScrollArea className='h-[500px] pr-4'>
+            <ScrollArea className='h-[calc(100dvh-15rem)] pr-3 sm:h-[500px] sm:pr-4'>
               {loading ? (
                 <div className='space-y-3'>
                   {Array.from({ length: 5 }).map((_, i) => (
-                    <div key={i} className='rounded-lg border p-4'>
+                    <div key={i} className='rounded-lg border p-3 sm:p-4'>
                       <div className='flex items-start justify-between'>
                         <div className='flex-1 space-y-2'>
                           <Skeleton className='h-4 w-48' />
@@ -132,7 +132,7 @@ export function BillingHistoryDialog({
                         </div>
                         <Skeleton className='h-5 w-16' />
                       </div>
-                      <div className='mt-3 grid grid-cols-3 gap-4'>
+                      <div className='mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4'>
                         <Skeleton className='h-3 w-full' />
                         <Skeleton className='h-3 w-full' />
                         <Skeleton className='h-3 w-full' />
@@ -141,7 +141,7 @@ export function BillingHistoryDialog({
                   ))}
                 </div>
               ) : records.length === 0 ? (
-                <div className='text-muted-foreground flex h-[400px] flex-col items-center justify-center text-center'>
+                <div className='text-muted-foreground flex h-[320px] flex-col items-center justify-center text-center sm:h-[400px]'>
                   <p className='text-sm font-medium'>
                     {t('No billing records found')}
                   </p>
@@ -158,13 +158,13 @@ export function BillingHistoryDialog({
                     return (
                       <div
                         key={record.id}
-                        className='hover:bg-muted/50 rounded-lg border p-4 transition-colors'
+                        className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
                       >
                         {/* Header Row */}
-                        <div className='flex items-start justify-between'>
+                        <div className='flex items-start justify-between gap-2'>
                           <div className='flex-1 space-y-1'>
-                            <div className='flex items-center gap-2'>
-                              <code className='text-foreground font-mono text-sm'>
+                            <div className='flex min-w-0 items-center gap-2'>
+                              <code className='text-foreground truncate font-mono text-sm'>
                                 {record.trade_no}
                               </code>
                               <Button
@@ -201,7 +201,7 @@ export function BillingHistoryDialog({
                         </div>
 
                         {/* Details Grid */}
-                        <div className='mt-4 grid grid-cols-3 gap-4'>
+                        <div className='mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3 sm:gap-4'>
                           <div className='space-y-1'>
                             <Label className='text-muted-foreground text-xs'>
                               Payment Method

+ 3 - 3
web/default/src/features/wallet/components/dialogs/creem-confirm-dialog.tsx

@@ -34,7 +34,7 @@ export function CreemConfirmDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className='sm:max-w-[425px]'>
+      <DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-[425px]'>
         <DialogHeader>
           <DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle>
           <DialogDescription>
@@ -42,7 +42,7 @@ export function CreemConfirmDialog({
           </DialogDescription>
         </DialogHeader>
 
-        <div className='space-y-4 py-4'>
+        <div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
           <div className='flex items-center justify-between'>
             <span className='text-muted-foreground'>{t('Product')}</span>
             <span className='font-medium'>{product.name}</span>
@@ -59,7 +59,7 @@ export function CreemConfirmDialog({
           </div>
         </div>
 
-        <DialogFooter>
+        <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
           <Button
             variant='outline'
             onClick={() => onOpenChange(false)}

+ 3 - 3
web/default/src/features/wallet/components/dialogs/payment-confirm-dialog.tsx

@@ -48,7 +48,7 @@ export function PaymentConfirmDialog({
 
   return (
     <AlertDialog open={open} onOpenChange={onOpenChange}>
-      <AlertDialogContent className='max-w-md'>
+      <AlertDialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
         <AlertDialogHeader>
           <AlertDialogTitle className='text-xl font-semibold'>
             {t('Confirm Payment')}
@@ -58,7 +58,7 @@ export function PaymentConfirmDialog({
           </AlertDialogDescription>
         </AlertDialogHeader>
 
-        <div className='space-y-4 py-4'>
+        <div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
           <div className='flex items-center justify-between'>
             <span className='text-muted-foreground text-sm'>
               {t('Topup Amount')}
@@ -121,7 +121,7 @@ export function PaymentConfirmDialog({
           </div>
         </div>
 
-        <AlertDialogFooter>
+        <AlertDialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
           <AlertDialogCancel disabled={processing}>
             {t('Cancel')}
           </AlertDialogCancel>

+ 3 - 3
web/default/src/features/wallet/components/dialogs/transfer-dialog.tsx

@@ -49,7 +49,7 @@ export function TransferDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className='max-w-md'>
+      <DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
         <DialogHeader>
           <DialogTitle className='text-xl font-semibold'>
             {t('Transfer Rewards')}
@@ -59,7 +59,7 @@ export function TransferDialog({
           </DialogDescription>
         </DialogHeader>
 
-        <div className='space-y-6 py-4'>
+        <div className='space-y-4 py-3 sm:space-y-6 sm:py-4'>
           <div className='space-y-2'>
             <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
               {t('Available Rewards')}
@@ -92,7 +92,7 @@ export function TransferDialog({
           </div>
         </div>
 
-        <DialogFooter>
+        <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
           <Button
             variant='outline'
             onClick={() => onOpenChange(false)}

+ 40 - 53
web/default/src/features/wallet/components/recharge-form-card.tsx

@@ -8,13 +8,12 @@ import { Button } from '@/components/ui/button'
 import {
   Card,
   CardContent,
-  CardDescription,
   CardHeader,
-  CardTitle,
 } from '@/components/ui/card'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Skeleton } from '@/components/ui/skeleton'
+import { TitledCard } from '@/components/ui/titled-card'
 import {
   Tooltip,
   TooltipContent,
@@ -125,13 +124,13 @@ export function RechargeFormCard({
 
   if (loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
+      <Card 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' />
         </CardHeader>
-        <CardContent className='space-y-6 pt-6'>
-          <div className='space-y-6'>
+        <CardContent className='space-y-4 p-3 sm:space-y-6 sm:p-5'>
+          <div className='space-y-4 sm:space-y-6'>
             {/* Preset Amounts Skeleton */}
             <div className='space-y-3'>
               <Skeleton className='h-3 w-16' />
@@ -173,23 +172,12 @@ export function RechargeFormCard({
   }
 
   return (
-    <Card className='overflow-hidden'>
-      <CardHeader className='border-b'>
-        <div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
-          <div className='flex min-w-0 items-center gap-3'>
-            <div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
-              <WalletCards className='h-4 w-4' />
-            </div>
-            <div className='min-w-0'>
-              <CardTitle className='text-xl tracking-tight'>
-                {t('Add Funds')}
-              </CardTitle>
-              <CardDescription>
-                {t('Choose an amount and payment method')}
-              </CardDescription>
-            </div>
-          </div>
-          {onOpenBilling && (
+    <TitledCard
+      title={t('Add Funds')}
+      description={t('Choose an amount and payment method')}
+      icon={<WalletCards className='h-4 w-4' />}
+      action={
+        onOpenBilling ? (
             <Button
               variant='outline'
               size='sm'
@@ -199,21 +187,21 @@ export function RechargeFormCard({
               <Receipt className='h-4 w-4' />
               {t('Order History')}
             </Button>
-          )}
-        </div>
-      </CardHeader>
-      <CardContent className='space-y-6 pt-6'>
+        ) : null
+      }
+      contentClassName='space-y-4 sm:space-y-6'
+    >
         {/* Online Topup Section */}
         {hasAnyTopup ? (
-          <div className='space-y-6'>
+          <div className='space-y-4 sm:space-y-6'>
             {hasConfigurableTopup && (
               <>
                 {presetAmounts.length > 0 && (
-                  <div className='space-y-3'>
+                  <div className='space-y-2.5 sm:space-y-3'>
                     <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
                       {t('Amount')}
                     </Label>
-                    <div className='grid grid-cols-2 gap-3 md:grid-cols-4'>
+                    <div className='grid grid-cols-2 gap-1.5 sm:gap-3 md:grid-cols-4'>
                       {presetAmounts.map((preset, index) => {
                         const discount =
                           preset.discount ||
@@ -235,7 +223,7 @@ export function RechargeFormCard({
                             key={index}
                             variant='outline'
                             className={cn(
-                              'hover:border-foreground flex h-auto flex-col items-start rounded-lg p-4 text-left whitespace-normal',
+                              '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',
                               selectedPreset === preset.value
                                 ? 'border-foreground bg-foreground/5'
                                 : 'border-muted'
@@ -243,7 +231,7 @@ export function RechargeFormCard({
                             onClick={() => onSelectPreset(preset)}
                           >
                             <div className='flex w-full items-center justify-between'>
-                              <div className='text-lg font-semibold'>
+                              <div className='text-base font-semibold sm:text-lg'>
                                 {formatNumber(displayValue)}
                               </div>
                               {hasDiscount && (
@@ -252,7 +240,7 @@ export function RechargeFormCard({
                                 </div>
                               )}
                             </div>
-                            <div className='text-muted-foreground mt-2 w-full text-xs'>
+                            <div className='text-muted-foreground mt-1.5 w-full text-xs sm:mt-2'>
                               Pay {formatCurrency(actualPrice)}
                               {hasDiscount && savedAmount > 0 && (
                                 <span className='text-green-600'>
@@ -268,14 +256,14 @@ export function RechargeFormCard({
                   </div>
                 )}
 
-                <div className='space-y-3'>
+                <div className='space-y-2.5 sm:space-y-3'>
                   <Label
                     htmlFor='topup-amount'
                     className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
                   >
                     {t('Custom Amount')}
                   </Label>
-                  <div className='grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center'>
+                  <div className='grid grid-cols-[minmax(0,1fr)_minmax(110px,0.55fr)] gap-2 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center'>
                     <Input
                       id='topup-amount'
                       type='number'
@@ -283,10 +271,10 @@ export function RechargeFormCard({
                       onChange={(e) => handleAmountChange(e.target.value)}
                       min={minTopup}
                       placeholder={`Minimum ${minTopup}`}
-                      className='text-lg'
+                      className='h-9 text-base sm:h-10 sm:text-lg'
                     />
-                    <div className='bg-muted/30 flex min-h-10 items-center justify-between gap-3 rounded-md border px-3 lg:min-w-52'>
-                      <span className='text-muted-foreground text-xs'>
+                    <div className='bg-muted/30 flex min-h-9 items-center justify-between gap-2 rounded-md border px-3 lg:min-w-52'>
+                      <span className='text-muted-foreground truncate text-xs'>
                         {t('Amount to pay:')}
                       </span>
                       {calculating ? (
@@ -300,12 +288,12 @@ export function RechargeFormCard({
                   </div>
                 </div>
 
-                <div className='space-y-3'>
+                <div className='space-y-2.5 sm:space-y-3'>
                   <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
                     {t('Payment Method')}
                   </Label>
                   {hasStandardPaymentMethods ? (
-                    <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'>
+                    <div className='grid grid-cols-2 gap-1.5 sm:gap-3 lg:grid-cols-3'>
                       {topupInfo?.pay_methods?.map((method) => {
                         const minTopup = method.min_topup || 0
                         const disabled = minTopup > topupAmount
@@ -316,7 +304,7 @@ export function RechargeFormCard({
                             variant='outline'
                             onClick={() => onPaymentMethodSelect(method)}
                             disabled={disabled || !!paymentLoading}
-                            className='justify-start gap-2 rounded-lg'
+                            className='h-9 min-w-0 justify-start gap-2 rounded-lg px-3'
                           >
                             {paymentLoading === method.type ? (
                               <Loader2 className='h-4 w-4 animate-spin' />
@@ -328,7 +316,7 @@ export function RechargeFormCard({
                                 method.name
                               )
                             )}
-                            {method.name}
+                            <span className='truncate'>{method.name}</span>
                           </Button>
                         )
 
@@ -362,11 +350,11 @@ export function RechargeFormCard({
                 {enableWaffoTopup &&
                   hasWaffoPaymentMethods &&
                   onWaffoMethodSelect && (
-                    <div className='space-y-3'>
+                    <div className='space-y-2.5 sm:space-y-3'>
                       <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
                         {t('Waffo Payment')}
                       </Label>
-                      <div className='grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3'>
+                      <div className='grid grid-cols-2 gap-1.5 sm:gap-3 lg:grid-cols-3'>
                         {waffoPayMethods?.map((method, index) => {
                           const loadingKey = `waffo-${index}`
                           const waffoMin = waffoMinTopup || 0
@@ -378,7 +366,7 @@ export function RechargeFormCard({
                               variant='outline'
                               onClick={() => onWaffoMethodSelect(method, index)}
                               disabled={belowMin || !!paymentLoading}
-                              className='justify-start gap-2 rounded-lg'
+                              className='h-9 min-w-0 justify-start gap-2 rounded-lg px-3'
                             >
                               {paymentLoading === loadingKey ? (
                                 <Loader2 className='h-4 w-4 animate-spin' />
@@ -391,7 +379,7 @@ export function RechargeFormCard({
                               ) : (
                                 getPaymentIcon('waffo')
                               )}
-                              {method.name}
+                              <span className='truncate'>{method.name}</span>
                             </Button>
                           )
 
@@ -433,7 +421,7 @@ export function RechargeFormCard({
           Array.isArray(creemProducts) &&
           creemProducts.length > 0 &&
           onCreemProductSelect && (
-            <div className='space-y-3 border-t pt-6'>
+            <div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
               <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
                 {t('Creem Payment')}
               </Label>
@@ -445,7 +433,7 @@ export function RechargeFormCard({
           )}
 
         {/* Redemption Code Section */}
-        <div className='space-y-3 border-t pt-6'>
+        <div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
           <div className='flex items-center gap-2'>
             <Gift className='text-muted-foreground h-4 w-4' />
             <Label
@@ -455,19 +443,19 @@ export function RechargeFormCard({
               {t('Have a Code?')}
             </Label>
           </div>
-          <div className='flex flex-col gap-2 sm:flex-row'>
+          <div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
             <Input
               id='redemption-code'
               value={redemptionCode}
               onChange={(e) => onRedemptionCodeChange(e.target.value)}
               placeholder={t('Enter your redemption code')}
-              className='flex-1'
+              className='h-9 min-w-0'
             />
             <Button
               onClick={onRedeem}
               disabled={redeeming}
               variant='outline'
-              className='sm:w-auto'
+              className='h-9 px-4'
             >
               {redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
               {t('Redeem')}
@@ -488,7 +476,6 @@ export function RechargeFormCard({
             </p>
           )}
         </div>
-      </CardContent>
-    </Card>
+    </TitledCard>
   )
 }

+ 31 - 35
web/default/src/features/wallet/components/subscription-plans-card.tsx

@@ -9,9 +9,7 @@ import { Button } from '@/components/ui/button'
 import {
   Card,
   CardContent,
-  CardDescription,
   CardHeader,
-  CardTitle,
 } from '@/components/ui/card'
 import { Progress } from '@/components/ui/progress'
 import {
@@ -23,6 +21,7 @@ import {
 } from '@/components/ui/select'
 import { Separator } from '@/components/ui/separator'
 import { Skeleton } from '@/components/ui/skeleton'
+import { TitledCard } from '@/components/ui/titled-card'
 import {
   Tooltip,
   TooltipContent,
@@ -48,6 +47,7 @@ import type { PaymentMethod, TopupInfo } from '../types'
 
 interface SubscriptionPlansCardProps {
   topupInfo: TopupInfo | null
+  onAvailabilityChange?: (available: boolean) => void
 }
 
 function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] {
@@ -56,7 +56,10 @@ function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] {
   )
 }
 
-export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
+export function SubscriptionPlansCard({
+  topupInfo,
+  onAvailabilityChange,
+}: SubscriptionPlansCardProps) {
   const { t } = useTranslation()
   const { status } = useStatus()
 
@@ -76,11 +79,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
   const [selectedPlan, setSelectedPlan] = useState<PlanRecord | null>(null)
 
   const enableStripe = !!status?.enable_stripe_topup
-  const enableCreem = !!props.topupInfo?.enable_creem_topup
+  const enableCreem = !!topupInfo?.enable_creem_topup
   const enableOnlineTopUp = !!status?.enable_online_topup
   const epayMethods = useMemo(
-    () => getEpayMethods(props.topupInfo?.pay_methods),
-    [props.topupInfo?.pay_methods]
+    () => getEpayMethods(topupInfo?.pay_methods),
+    [topupInfo?.pay_methods]
   )
 
   const fetchPlans = useCallback(async () => {
@@ -148,6 +151,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
 
   const hasActive = activeSubscriptions.length > 0
   const hasAny = allSubscriptions.length > 0
+  const isAvailable = loading || plans.length > 0 || hasAny
   const disablePref = !hasActive
   const isSubPref =
     billingPreference === 'subscription_first' ||
@@ -165,6 +169,10 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
     return map
   }, [allSubscriptions])
 
+  useEffect(() => {
+    onAvailabilityChange?.(isAvailable)
+  }, [isAvailable, onAvailabilityChange])
+
   const planTitleMap = useMemo(() => {
     const map = new Map<number, string>()
     for (const p of plans) {
@@ -191,11 +199,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
 
   if (loading) {
     return (
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
+      <Card 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>
-        <CardContent className='space-y-4 pt-6'>
+        <CardContent className='space-y-4 p-3 sm:p-5'>
           <Skeleton className='h-20 w-full' />
           <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
             {Array.from({ length: 3 }).map((_, i) => (
@@ -213,27 +221,16 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
 
   return (
     <>
-      <Card className='overflow-hidden'>
-        <CardHeader className='border-b'>
-          <div className='flex items-center gap-3'>
-            <div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
-              <Crown className='h-4 w-4' />
-            </div>
-            <div className='min-w-0'>
-              <CardTitle className='text-xl tracking-tight'>
-                {t('Subscription Plans')}
-              </CardTitle>
-              <CardDescription>
-                {t('Purchase a plan to enjoy model benefits')}
-              </CardDescription>
-            </div>
-          </div>
-        </CardHeader>
-        <CardContent className='space-y-5 pt-6'>
+      <TitledCard
+        title={t('Subscription Plans')}
+        description={t('Purchase a plan to enjoy model benefits')}
+        icon={<Crown className='h-4 w-4' />}
+        contentClassName='space-y-4 sm:space-y-5'
+      >
           {/* My subscriptions & billing preference */}
-          <div className='rounded-xl border p-4'>
-            <div className='flex flex-wrap items-center justify-between gap-3'>
-              <div className='flex items-center gap-2'>
+          <div className='rounded-xl border p-3 sm:p-4'>
+            <div className='flex flex-wrap items-center justify-between gap-2.5 sm:gap-3'>
+              <div className='flex min-w-0 flex-wrap items-center gap-2'>
                 <span className='text-sm font-medium'>
                   {t('My Subscriptions')}
                 </span>
@@ -265,12 +262,12 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
                   )}
                 </span>
               </div>
-              <div className='flex items-center gap-2'>
+              <div className='flex w-full items-center gap-2 sm:w-auto'>
                 <Select
                   value={displayPref}
                   onValueChange={handlePreferenceChange}
                 >
-                  <SelectTrigger className='h-8 w-[140px] text-xs'>
+                  <SelectTrigger className='h-8 flex-1 text-xs sm:w-[140px] sm:flex-none'>
                     <SelectValue />
                   </SelectTrigger>
                   <SelectContent>
@@ -452,7 +449,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
 
           {/* Available plans grid */}
           {plans.length > 0 ? (
-            <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
+            <div className='grid grid-cols-1 gap-3 2xl:grid-cols-2 2xl:gap-4'>
               {plans.map((p, index) => {
                 const plan = p?.plan
                 if (!plan) return null
@@ -485,7 +482,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
                       isPopular && 'border-primary/70 shadow-sm'
                     )}
                   >
-                    <CardContent className='flex h-full flex-col p-4'>
+                    <CardContent className='flex h-full flex-col p-3.5 sm:p-4'>
                       <div className='mb-2 flex items-start justify-between gap-3'>
                         <div className='min-w-0'>
                           <h4 className='truncate font-semibold'>
@@ -568,8 +565,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
               {t('No plans available')}
             </p>
           )}
-        </CardContent>
-      </Card>
+      </TitledCard>
 
       <SubscriptionPurchaseDialog
         open={purchaseOpen}

+ 5 - 5
web/default/src/features/wallet/components/wallet-stats-card.tsx

@@ -14,9 +14,9 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
   if (props.loading) {
     return (
       <div className='overflow-hidden rounded-lg border'>
-        <div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
+        <div className='divide-border/60 grid grid-cols-3 divide-x'>
           {Array.from({ length: 3 }).map((_, i) => (
-            <div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
+            <div key={i} className='px-3 py-3 sm:px-5 sm:py-4'>
               <Skeleton className='h-3.5 w-20' />
               <Skeleton className='mt-2 h-7 w-28' />
               <Skeleton className='mt-1.5 h-3.5 w-24' />
@@ -50,9 +50,9 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
 
   return (
     <div className='overflow-hidden rounded-lg border'>
-      <div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
+      <div className='divide-border/60 grid grid-cols-3 divide-x'>
         {stats.map((item) => (
-          <div key={item.label} className='px-4 py-3.5 sm:px-5 sm:py-4'>
+          <div key={item.label} className='px-3 py-3 sm:px-5 sm:py-4'>
             <div className='flex items-center gap-2'>
               <item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
               <div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
@@ -60,7 +60,7 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
               </div>
             </div>
 
-            <div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight break-all tabular-nums'>
+            <div className='text-foreground mt-1.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:mt-2 sm:text-2xl'>
               {item.value}
             </div>
             <div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>

+ 28 - 13
web/default/src/features/wallet/index.tsx

@@ -54,6 +54,7 @@ export function Wallet(props: WalletProps) {
   const [creemDialogOpen, setCreemDialogOpen] = useState(false)
   const [selectedCreemProduct, setSelectedCreemProduct] =
     useState<CreemProduct | null>(null)
+  const [showSubscriptionPanel, setShowSubscriptionPanel] = useState(true)
 
   const { status } = useStatus()
   const { currency } = useSystemConfig()
@@ -231,6 +232,13 @@ export function Wallet(props: WalletProps) {
     return topupInfo?.discount?.[topupAmount] || DEFAULT_DISCOUNT_RATE
   }, [topupInfo, topupAmount])
 
+  const handleSubscriptionAvailabilityChange = useCallback(
+    (available: boolean) => {
+      setShowSubscriptionPanel(available)
+    },
+    []
+  )
+
   return (
     <>
       <SectionPageLayout>
@@ -239,13 +247,17 @@ export function Wallet(props: WalletProps) {
           {t('Manage your balance and payment methods')}
         </SectionPageLayout.Description>
         <SectionPageLayout.Content>
-          <div className='mx-auto flex w-full max-w-7xl flex-col gap-4'>
+          <div className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-5'>
             <WalletStatsCard user={user} loading={userLoading} />
 
-            <SubscriptionPlansCard topupInfo={topupInfo} />
-
-            <div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.4fr)] xl:items-start'>
-              <div className='min-w-0'>
+            <div
+              className={
+                showSubscriptionPanel
+                  ? 'grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(360px,0.95fr)] xl:items-start'
+                  : 'grid gap-4'
+              }
+            >
+              <div id='wallet-add-funds' className='scroll-mt-4'>
                 <RechargeFormCard
                   topupInfo={topupInfo}
                   presetAmounts={presetAmounts}
@@ -279,15 +291,18 @@ export function Wallet(props: WalletProps) {
                 />
               </div>
 
-              <div className='xl:sticky xl:top-6'>
-                <AffiliateRewardsCard
-                  user={user}
-                  affiliateLink={affiliateLink}
-                  onTransfer={() => setTransferDialogOpen(true)}
-                  loading={affiliateLoading}
-                />
-              </div>
+              <SubscriptionPlansCard
+                topupInfo={topupInfo}
+                onAvailabilityChange={handleSubscriptionAvailabilityChange}
+              />
             </div>
+
+            <AffiliateRewardsCard
+              user={user}
+              affiliateLink={affiliateLink}
+              onTransfer={() => setTransferDialogOpen(true)}
+              loading={affiliateLoading}
+            />
           </div>
         </SectionPageLayout.Content>
       </SectionPageLayout>

+ 6 - 0
web/default/src/i18n/locales/en.json

@@ -1817,6 +1817,7 @@
     "Inter-group ratio overrides": "Inter-group ratio overrides",
     "Internal Notes": "Internal Notes",
     "Internal notes (not shown to users)": "Internal notes (not shown to users)",
+    "Interface Language": "Interface Language",
     "Internal Server Error!": "Internal Server Error!",
     "Invalid chat link. Please contact the administrator.": "Invalid chat link. Please contact the administrator.",
     "Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.",
@@ -1893,6 +1894,9 @@
     "Kling": "Kling",
     "Knowledge Base ID *": "Knowledge Base ID *",
     "Landing page with system overview.": "Landing page with system overview.",
+    "Language Preferences": "Language Preferences",
+    "Language preference saved": "Language preference saved",
+    "Language preferences sync across your signed-in devices and affect API error messages.": "Language preferences sync across your signed-in devices and affect API error messages.",
     "Last check time": "Last check time",
     "Last detected addable models": "Last detected addable models",
     "Last Login": "Last Login",
@@ -3108,6 +3112,7 @@
     "Select items...": "Select items...",
     "Select key format": "Select key format",
     "Select Language": "Select Language",
+    "Select language": "Select language",
     "Select layout style": "Select layout style",
     "Select locations": "Select locations",
     "Select Model": "Select Model",
@@ -3171,6 +3176,7 @@
     "Set quota amount and limits": "Set quota amount and limits",
     "Set Request Header": "Set Request Header",
     "Set runtime request header: override entire value, or manipulate comma-separated tokens": "Set runtime request header: override entire value, or manipulate comma-separated tokens",
+    "Set the language used across the interface": "Set the language used across the interface",
     "Set Tag": "Set Tag",
     "Set tag for selected channels": "Set tag for selected channels",
     "Set the user's role (cannot be Root)": "Set the user's role (cannot be Root)",

+ 6 - 0
web/default/src/i18n/locales/fr.json

@@ -1817,6 +1817,7 @@
     "Inter-group ratio overrides": "Dérogations de ratio inter-groupes",
     "Internal Notes": "Notes internes",
     "Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)",
+    "Interface Language": "Langue de l'interface",
     "Internal Server Error!": "Erreur interne du serveur !",
     "Invalid chat link. Please contact the administrator.": "Lien de chat invalide. Veuillez contacter l'administrateur.",
     "Invalid chat link. Please contact your administrator.": "Lien de chat invalide. Veuillez contacter votre administrateur.",
@@ -1893,6 +1894,9 @@
     "Kling": "Kling",
     "Knowledge Base ID *": "ID de la base de connaissances *",
     "Landing page with system overview.": "Page d'accueil avec aperçu du système.",
+    "Language Preferences": "Préférences de langue",
+    "Language preference saved": "Préférence de langue enregistrée",
+    "Language preferences sync across your signed-in devices and affect API error messages.": "Les préférences de langue se synchronisent sur vos appareils connectés et affectent les messages d'erreur de l'API.",
     "Last check time": "Dernière vérification",
     "Last detected addable models": "Derniers modèles ajoutables détectés",
     "Last Login": "Dernière connexion",
@@ -3108,6 +3112,7 @@
     "Select items...": "Sélectionner des éléments...",
     "Select key format": "Sélectionner le format de clé",
     "Select Language": "Sélectionner la langue",
+    "Select language": "Sélectionner une langue",
     "Select layout style": "Sélectionner le style de mise en page",
     "Select locations": "Sélectionner des emplacements",
     "Select Model": "Sélectionner le modèle",
@@ -3171,6 +3176,7 @@
     "Set quota amount and limits": "Définir le quota et les limites",
     "Set Request Header": "Définir un en-tête de requête",
     "Set runtime request header: override entire value, or manipulate comma-separated tokens": "Définir l'en-tête de requête : remplacer la valeur ou manipuler les tokens séparés par des virgules",
+    "Set the language used across the interface": "Définir la langue utilisée dans l'interface",
     "Set Tag": "Définir un tag",
     "Set tag for selected channels": "Définir un tag pour les canaux sélectionnés",
     "Set the user's role (cannot be Root)": "Définir le rôle de l'utilisateur (ne peut pas être Root)",

+ 6 - 0
web/default/src/i18n/locales/ja.json

@@ -1817,6 +1817,7 @@
     "Inter-group ratio overrides": "グループ間比率上書き",
     "Internal Notes": "内部メモ",
     "Internal notes (not shown to users)": ":内部メモ(ユーザーには表示されません)",
+    "Interface Language": "インターフェース言語",
     "Internal Server Error!": "内部サーバーエラー!",
     "Invalid chat link. Please contact the administrator.": "無効なチャットリンクです。管理者に連絡してください。",
     "Invalid chat link. Please contact your administrator.": "無効なチャットリンクです。管理者に連絡してください。",
@@ -1893,6 +1894,9 @@
     "Kling": "Kling",
     "Knowledge Base ID *": "ナレッジベースID *",
     "Landing page with system overview.": "システム概要付きランディングページ。",
+    "Language Preferences": "言語設定",
+    "Language preference saved": "言語設定を保存しました",
+    "Language preferences sync across your signed-in devices and affect API error messages.": "言語設定はログイン中のすべてのデバイスで同期され、API のエラーメッセージ言語にも反映されます。",
     "Last check time": "最終チェック時刻",
     "Last detected addable models": "最後に検出された追加可能モデル",
     "Last Login": "最終ログイン",
@@ -3108,6 +3112,7 @@
     "Select items...": "項目を選択...",
     "Select key format": "キーフォーマットを選択",
     "Select Language": "言語を選択",
+    "Select language": "言語を選択",
     "Select layout style": "レイアウトスタイルを選択",
     "Select locations": "ロケーションを選択",
     "Select Model": "モデルを選択",
@@ -3171,6 +3176,7 @@
     "Set quota amount and limits": "クォータ量と制限を設定",
     "Set Request Header": "リクエストヘッダーを設定",
     "Set runtime request header: override entire value, or manipulate comma-separated tokens": "ランタイムリクエストヘッダーを設定:値全体を上書き、またはカンマ区切りトークンを操作",
+    "Set the language used across the interface": "インターフェースで使用する言語を設定します",
     "Set Tag": "タグを設定",
     "Set tag for selected channels": "選択したチャネルにタグを設定",
     "Set the user's role (cannot be Root)": "ユーザーのロールを設定します(Rootにはできません)",

+ 6 - 0
web/default/src/i18n/locales/ru.json

@@ -1817,6 +1817,7 @@
     "Inter-group ratio overrides": "Переопределения соотношений между группами",
     "Internal Notes": "Внутренние заметки",
     "Internal notes (not shown to users)": "Внутренние заметки (не показываются пользователям)",
+    "Interface Language": "Язык интерфейса",
     "Internal Server Error!": "Внутренняя ошибка сервера!",
     "Invalid chat link. Please contact the administrator.": "Неверная ссылка на чат. Пожалуйста, обратитесь к администратору.",
     "Invalid chat link. Please contact your administrator.": "Недействительная ссылка чата. Обратитесь к администратору.",
@@ -1893,6 +1894,9 @@
     "Kling": "Kling",
     "Knowledge Base ID *": "ID базы знаний *",
     "Landing page with system overview.": "Главная страница с обзором системы.",
+    "Language Preferences": "Языковые настройки",
+    "Language preference saved": "Языковая настройка сохранена",
+    "Language preferences sync across your signed-in devices and affect API error messages.": "Языковые настройки синхронизируются на всех ваших устройствах после входа и влияют на язык сообщений об ошибках API.",
     "Last check time": "Время последней проверки",
     "Last detected addable models": "Последние обнаруженные модели для добавления",
     "Last Login": "Последний вход",
@@ -3108,6 +3112,7 @@
     "Select items...": "Выберите элементы...",
     "Select key format": "Выберите формат ключа",
     "Select Language": "Выбрать язык",
+    "Select language": "Выберите язык",
     "Select layout style": "Выбрать стиль макета",
     "Select locations": "Выбрать локации",
     "Select Model": "Выбрать модель",
@@ -3171,6 +3176,7 @@
     "Set quota amount and limits": "Настройте квоту и лимиты",
     "Set Request Header": "Установить заголовок запроса",
     "Set runtime request header: override entire value, or manipulate comma-separated tokens": "Установить заголовок запроса: переопределить значение или управлять токенами через запятую",
+    "Set the language used across the interface": "Настроить язык интерфейса",
     "Set Tag": "Установить тег",
     "Set tag for selected channels": "Установить тег для выбранных каналов",
     "Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)",

+ 6 - 0
web/default/src/i18n/locales/vi.json

@@ -1817,6 +1817,7 @@
     "Inter-group ratio overrides": "Tỷ lệ liên nhóm ghi đè",
     "Internal Notes": "Ghi chú nội bộ",
     "Internal notes (not shown to users)": "Ghi chú nội bộ (không hiển thị cho người dùng)",
+    "Interface Language": "Ngôn ngữ giao diện",
     "Internal Server Error!": "Lỗi máy chủ nội bộ!",
     "Invalid chat link. Please contact the administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ quản trị viên.",
     "Invalid chat link. Please contact your administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ với quản trị viên của bạn.",
@@ -1893,6 +1894,9 @@
     "Kling": "Kling",
     "Knowledge Base ID *": "Mã số Cơ sở kiến thức *",
     "Landing page with system overview.": "Trang chủ với tổng quan hệ thống.",
+    "Language Preferences": "Tùy chọn ngôn ngữ",
+    "Language preference saved": "Đã lưu tùy chọn ngôn ngữ",
+    "Language preferences sync across your signed-in devices and affect API error messages.": "Tùy chọn ngôn ngữ sẽ đồng bộ trên các thiết bị đã đăng nhập và ảnh hưởng đến ngôn ngữ thông báo lỗi API.",
     "Last check time": "Thời gian kiểm tra gần nhất",
     "Last detected addable models": "Mô hình có thể thêm được phát hiện gần nhất",
     "Last Login": "Lần đăng nhập cuối",
@@ -3108,6 +3112,7 @@
     "Select items...": "Chọn các mục...",
     "Select key format": "Chọn định dạng khóa",
     "Select Language": "Chọn Ngôn ngữ",
+    "Select language": "Chọn ngôn ngữ",
     "Select layout style": "Chọn kiểu bố cục",
     "Select locations": "Chọn vị trí",
     "Select Model": "Chọn mẫu",
@@ -3171,6 +3176,7 @@
     "Set quota amount and limits": "Thiết lập hạn mức và giới hạn",
     "Set Request Header": "Đặt header yêu cầu",
     "Set runtime request header: override entire value, or manipulate comma-separated tokens": "Đặt header yêu cầu runtime: ghi đè toàn bộ giá trị hoặc thao tác token phân cách bằng dấu phẩy",
+    "Set the language used across the interface": "Đặt ngôn ngữ sử dụng trong giao diện",
     "Set Tag": "Gán Thẻ",
     "Set tag for selected channels": "Đặt thẻ cho các kênh đã chọn",
     "Set the user's role (cannot be Root)": "Đặt vai trò của người dùng (không được là Root)",

+ 6 - 0
web/default/src/i18n/locales/zh.json

@@ -1817,6 +1817,7 @@
     "Inter-group ratio overrides": "分组间比例覆盖",
     "Internal Notes": "内部备注",
     "Internal notes (not shown to users)": "内部备注(不显示给用户)",
+    "Interface Language": "界面语言",
     "Internal Server Error!": "内部服务器错误!",
     "Invalid chat link. Please contact the administrator.": "无效的聊天链接。请联系管理员。",
     "Invalid chat link. Please contact your administrator.": "无效的聊天链接。请联系您的管理员。",
@@ -1893,6 +1894,9 @@
     "Kling": "Kling",
     "Knowledge Base ID *": "知识库 ID *",
     "Landing page with system overview.": "带有系统概览的登陆页面。",
+    "Language Preferences": "语言偏好",
+    "Language preference saved": "语言偏好已保存",
+    "Language preferences sync across your signed-in devices and affect API error messages.": "语言偏好会同步到您登录的所有设备,并影响 API 错误消息语言。",
     "Last check time": "上次检测时间",
     "Last detected addable models": "上次检测到可加入模型",
     "Last Login": "最后登录",
@@ -3108,6 +3112,7 @@
     "Select items...": "选择项目...",
     "Select key format": "请选择密钥格式",
     "Select Language": "选择语言",
+    "Select language": "选择语言",
     "Select layout style": "选择布局样式",
     "Select locations": "选择位置",
     "Select Model": "选择模型",
@@ -3171,6 +3176,7 @@
     "Set quota amount and limits": "设置令牌可用额度和数量",
     "Set Request Header": "设置请求头",
     "Set runtime request header: override entire value, or manipulate comma-separated tokens": "设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做处理",
+    "Set the language used across the interface": "设置界面显示语言",
     "Set Tag": "设置标签",
     "Set tag for selected channels": "为选定的渠道设置标签",
     "Set the user's role (cannot be Root)": "设置用户角色(不能是 Root)",

+ 14 - 0
web/default/src/lib/time.ts

@@ -65,6 +65,20 @@ export function getNormalizedDateRange(
   }
 }
 
+/**
+ * Calculate a rolling date range ending at the current moment.
+ * Example: 1 day means the last 24 hours, not yesterday 00:00 to today 23:59.
+ */
+export function getRollingDateRange(
+  days: number,
+  fromDate: Date = new Date()
+): { start: Date; end: Date } {
+  const end = new Date(fromDate)
+  const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000)
+
+  return { start, end }
+}
+
 /**
  * Compute time range as Unix timestamps (seconds)
  * @param days Default number of days if no dates provided

+ 1 - 1
web/default/src/stores/auth-store.ts

@@ -26,7 +26,7 @@ export interface AuthUser {
   wechat_id?: string
   telegram_id?: string
   linux_do_id?: string
-  setting?: Record<string, unknown>
+  setting?: Record<string, unknown> | string
   stripe_customer?: string
   sidebar_modules?: string
   permissions?: UserPermissions