feat(ui): improve mobile responsive layouts

This commit is contained in:
CaIon 2026-04-30 19:53:02 +08:00
parent aa730395f1
commit d46df94f05
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
84 changed files with 1174 additions and 731 deletions

View File

@ -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'>

View File

@ -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'

View File

@ -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}

View File

@ -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]'>

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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)
}

View File

@ -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...')}

View File

@ -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')}

View File

@ -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}`}

View File

@ -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'>

View File

@ -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}`}

View File

@ -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')}

View File

@ -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'
)}
>

View File

@ -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'>

View File

@ -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}

View File

@ -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}`

View File

@ -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'>

View File

@ -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 &&

View File

@ -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>
)
}

View File

@ -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>
</>

View File

@ -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' />
) : (

View File

@ -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})`

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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...')}

View File

@ -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'

View File

@ -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>

View File

@ -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 =

View File

@ -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')}

View File

@ -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')}

View File

@ -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...')}

View File

@ -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'>

View File

@ -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'

View File

@ -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')}

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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
*/

View File

@ -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>
)
}

View File

@ -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>
)}
{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>
{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>
)}
</CardContent>
</Card>

View File

@ -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'>

View File

@ -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

View File

@ -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>
)
}

View File

@ -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 (

View File

@ -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')}

View File

@ -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>

View File

@ -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'>

View File

@ -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}

View File

@ -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
}
/**

View File

@ -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>

View File

@ -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...')}

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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,
}

View File

@ -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'

View File

@ -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>

View File

@ -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')}

View File

@ -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'

View File

@ -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} />}

View File

@ -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>

View File

@ -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...')}

View File

@ -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>
</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>
</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>
{/* 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 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>
{/* 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>

View File

@ -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)}

View File

@ -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

View File

@ -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)}

View File

@ -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>

View File

@ -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)}

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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'>

View File

@ -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>

View File

@ -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)",

View File

@ -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)",

View File

@ -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にはできません",

View File

@ -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)",

View File

@ -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)",

View File

@ -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",

View File

@ -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

View File

@ -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