feat(ui): improve mobile responsive layouts

This commit is contained in:
CaIon
2026-04-30 19:53:02 +08:00
parent aa730395f1
commit d46df94f05
84 changed files with 1174 additions and 731 deletions
+4 -4
View File
@@ -62,7 +62,7 @@ function ListSkeleton() {
<Skeleton className='h-4 w-32' /> <Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-full' /> <Skeleton className='h-5 w-16 rounded-full' />
</div> </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'> <div className='flex-1'>
<Skeleton className='mb-1 h-2 w-8' /> <Skeleton className='mb-1 h-2 w-8' />
<Skeleton className='h-4 w-full' /> <Skeleton className='h-4 w-full' />
@@ -136,9 +136,9 @@ function CompactRow<TData>({ row }: { row: Row<TData> }) {
)} )}
</div> </div>
{/* Row 2: Key fields side by side */} {/* Row 2: Key fields wrap into compact columns instead of squeezing */}
{fieldCells.length > 0 && ( {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) => { {fieldCells.map((cell) => {
const label = getCellLabel(cell) const label = getCellLabel(cell)
return ( return (
@@ -260,7 +260,7 @@ export function MobileCardList<TData>(props: MobileCardListProps<TData>) {
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
return ( return (
<div className='rounded-lg border p-8'> <div className='rounded-lg border p-6'>
<Empty className='border-none p-0'> <Empty className='border-none p-0'>
<EmptyHeader> <EmptyHeader>
<EmptyMedia variant='icon'> <EmptyMedia variant='icon'>
+5 -5
View File
@@ -32,12 +32,12 @@ export function DataTablePagination<TData>({
<div <div
className={cn( className={cn(
'flex items-center justify-between overflow-clip', '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 }} style={{ overflowClipMargin: 1 }}
> >
<div className='flex w-full items-center justify-between'> <div className='flex w-full items-center justify-between gap-2'>
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @2xl/content:hidden'> <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}}', { {t('Page {{current}} of {{total}}', {
current: currentPage, current: currentPage,
total: totalPages, total: totalPages,
@@ -50,7 +50,7 @@ export function DataTablePagination<TData>({
table.setPageSize(Number(value)) 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} /> <SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger> </SelectTrigger>
<SelectContent side='top'> <SelectContent side='top'>
@@ -74,7 +74,7 @@ export function DataTablePagination<TData>({
total: totalPages, total: totalPages,
})} })}
</div> </div>
<div className='flex items-center space-x-2'> <div className='flex items-center space-x-1.5 sm:space-x-2'>
<Button <Button
variant='outline' variant='outline'
className='size-8 p-0 @max-md/content:hidden' className='size-8 p-0 @max-md/content:hidden'
+5 -5
View File
@@ -64,14 +64,14 @@ export function DataTableToolbar<TData>({
onChange={(event) => onChange={(event) =>
table.getColumn(searchKey)?.setFilterValue(event.target.value) 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 <Input
placeholder={resolvedSearchPlaceholder} placeholder={resolvedSearchPlaceholder}
value={table.getState().globalFilter ?? ''} value={table.getState().globalFilter ?? ''}
onChange={(event) => table.setGlobalFilter(event.target.value)} 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 ( return (
<div className='space-y-2'> <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 */} {/* Search input */}
{customSearch !== undefined ? customSearch : searchInput} {customSearch !== undefined ? customSearch : searchInput}
@@ -122,7 +122,7 @@ export function DataTableToolbar<TData>({
<Button <Button
variant='outline' variant='outline'
size='sm' 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)} onClick={() => setMobileFiltersOpen((v) => !v)}
> >
<SlidersHorizontal className='h-3.5 w-3.5' /> <SlidersHorizontal className='h-3.5 w-3.5' />
@@ -142,7 +142,7 @@ export function DataTableToolbar<TData>({
{/* Mobile: collapsible filter area */} {/* Mobile: collapsible filter area */}
{hasFilterContent && mobileFiltersOpen && ( {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>} {additionalSearch && <div className='w-full'>{additionalSearch}</div>}
{filterChips} {filterChips}
{resetButton} {resetButton}
+2 -2
View File
@@ -25,10 +25,10 @@ export function DataTableViewOptions<TData>({
<Button <Button
variant='outline' variant='outline'
size='sm' 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' /> <MixerHorizontalIcon className='size-4' />
{t('View')} <span className='hidden sm:inline'>{t('View')}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'> <DropdownMenuContent align='end' className='w-[150px]'>
@@ -70,15 +70,15 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
<AppHeader /> <AppHeader />
<Main> <Main>
<div className='shrink-0 px-4 pt-4 pb-3 sm:pt-6 sm:pb-4'> <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-3'>{breadcrumb}</div>} {breadcrumb != null && <div className='mb-2 sm:mb-3'>{breadcrumb}</div>}
<div className='flex flex-wrap items-center justify-between gap-x-4 gap-y-2'> <div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
<div className='min-w-0'> <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} {title}
</h2> </h2>
{description != null && ( {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} {description}
</p> </p>
)} )}
@@ -91,11 +91,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div> </div>
</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 <div
ref={setFooterContainer} 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> </Main>
</PageFooterProvider> </PageFooterProvider>
+81
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>
)
}
+19 -3
View File
@@ -5,6 +5,24 @@ import { getSelf } from '@/lib/api'
import type { User } from '@/features/users/types' import type { User } from '@/features/users/types'
import { saveUserId } from '../lib/storage' 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 * Hook for handling authentication redirects and user data management
*/ */
@@ -39,9 +57,7 @@ export function useAuthRedirect() {
} }
// Restore saved language preference // Restore saved language preference
const savedLang = (user as Record<string, unknown>).language as const savedLang = getSavedLanguage(user)
| string
| undefined
if (savedLang && savedLang !== i18n.language) { if (savedLang && savedLang !== i18n.language) {
i18n.changeLanguage(savedLang) i18n.changeLanguage(savedLang)
} }
@@ -87,7 +87,10 @@ export function ChannelsTable() {
} = useTableUrlState({ } = useTableUrlState({
search: route.useSearch(), search: route.useSearch(),
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE }, pagination: {
defaultPage: 1,
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
},
globalFilter: { enabled: true, key: 'filter' }, globalFilter: { enabled: true, key: 'filter' },
columnFilters: [ columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' }, { columnId: 'status', searchKey: 'status', type: 'array' },
@@ -329,7 +332,7 @@ export function ChannelsTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<DataTableToolbar <DataTableToolbar
table={table} table={table}
searchPlaceholder={t('Filter by name, ID, or key...')} searchPlaceholder={t('Filter by name, ID, or key...')}
@@ -1082,8 +1082,8 @@ export function ChannelMutateDrawer({
return ( return (
<> <>
<Sheet open={open} onOpenChange={handleOpenChange}> <Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className='flex w-full flex-col sm:max-w-3xl'> <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl'>
<SheetHeader className='text-start'> <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle className='flex items-center gap-3'> <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'> <span className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border'>
{getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)} {getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)}
@@ -1110,10 +1110,10 @@ export function ChannelMutateDrawer({
<form <form
id='channel-form' id='channel-form'
onSubmit={form.handleSubmit(onSubmit)} 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 ── */} {/* ── 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 <CardHeading
title={t('Basic Information')} title={t('Basic Information')}
icon={<Server className='h-4 w-4' />} icon={<Server className='h-4 w-4' />}
@@ -3276,7 +3276,7 @@ export function ChannelMutateDrawer({
</form> </form>
</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> <SheetClose asChild>
<Button variant='outline' disabled={isSubmitting}> <Button variant='outline' disabled={isSubmitting}>
{t('Cancel')} {t('Cancel')}
@@ -77,7 +77,7 @@ export function ConsumptionDistributionChart(
return ( return (
<div className='overflow-hidden rounded-lg border'> <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'> <div className='flex items-center gap-2'>
<WalletCards className='text-muted-foreground/60 size-4' /> <WalletCards className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'> <div className='text-sm font-semibold'>
@@ -88,7 +88,7 @@ export function ConsumptionDistributionChart(
</span> </span>
</div> </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) => { {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((item) => {
const Icon = CHART_TYPE_ICONS[item.value] const Icon = CHART_TYPE_ICONS[item.value]
return ( return (
@@ -96,7 +96,7 @@ export function ConsumptionDistributionChart(
key={item.value} key={item.value}
type='button' type='button'
onClick={() => setChartType(item.value)} 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 chartType === item.value
? 'bg-background text-foreground shadow-sm' ? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
@@ -110,7 +110,7 @@ export function ConsumptionDistributionChart(
</div> </div>
</div> </div>
<div className='h-96 p-2'> <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
{themeReady && spec && ( {themeReady && spec && (
<VChart <VChart
key={`${chartType}-${resolvedTheme}`} key={`${chartType}-${resolvedTheme}`}
@@ -95,10 +95,13 @@ export function LogStatCards(props: LogStatCardsProps) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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'> <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 const Icon = it.icon
return ( 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'> <div className='flex items-center gap-2'>
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' /> <Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'> <div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
@@ -113,7 +116,7 @@ export function LogStatCards(props: LogStatCardsProps) {
</div> </div>
) : error ? ( ) : 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>
<div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'> <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} {it.value}
</div> </div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'> <div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
@@ -78,7 +78,7 @@ export function ModelCharts(props: ModelChartsProps) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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'> <div className='flex items-center gap-2'>
<PieChartIcon className='text-muted-foreground/60 size-4' /> <PieChartIcon className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'> <div className='text-sm font-semibold'>
@@ -89,13 +89,13 @@ export function ModelCharts(props: ModelChartsProps) {
</span> </span>
</div> </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) => ( {MODEL_ANALYTICS_CHART_OPTIONS.map((tab) => (
<button <button
key={tab.value} key={tab.value}
type='button' type='button'
onClick={() => setActiveTab(tab.value)} 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 activeTab === tab.value
? 'bg-background text-foreground shadow-sm' ? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
@@ -107,7 +107,7 @@ export function ModelCharts(props: ModelChartsProps) {
</div> </div>
</div> </div>
<div className='h-96 p-2'> <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
{themeReady && spec && ( {themeReady && spec && (
<VChart <VChart
key={`${activeTab}-${resolvedTheme}`} key={`${activeTab}-${resolvedTheme}`}
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react' import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store' 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 { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -88,7 +88,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
const handleReset = () => { const handleReset = () => {
const days = props.preferences.defaultTimeRangeDays const days = props.preferences.defaultTimeRangeDays
const { start, end } = getNormalizedDateRange(days) const { start, end } = getRollingDateRange(days)
setFilters({ setFilters({
...buildDefaultDashboardFilters(props.preferences), ...buildDefaultDashboardFilters(props.preferences),
start_timestamp: start, start_timestamp: start,
@@ -109,7 +109,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
} }
const handleQuickRange = (days: number) => { const handleQuickRange = (days: number) => {
const { start, end } = getNormalizedDateRange(days) const { start, end } = getRollingDateRange(days)
setFilters((prev) => ({ setFilters((prev) => ({
...prev, ...prev,
@@ -127,7 +127,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
{t('Filter')} {t('Filter')}
</Button> </Button>
</DialogTrigger> </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> <DialogHeader>
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle> <DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -137,15 +137,15 @@ export function ModelsFilter(props: ModelsFilterProps) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className='flex-1 pr-4'> <ScrollArea className='flex-1 pr-3 sm:pr-4'>
<div className='grid gap-4 py-4'> <div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
{/* Quick time range selection */} {/* Quick time range selection */}
<div className='grid gap-2'> <div className='grid gap-2'>
<Label className='flex items-center gap-2'> <Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' /> <Calendar className='h-4 w-4' />
{t('Quick Range')} {t('Quick Range')}
</Label> </Label>
<div className='flex gap-2'> <div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => ( {TIME_RANGE_PRESETS.map((range) => (
<Button <Button
key={range.days} key={range.days}
@@ -170,7 +170,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
<SectionDivider label={t('Custom Time Range')} /> <SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */} {/* Custom time range */}
<div className='grid gap-4'> <div className='grid gap-3 sm:gap-4'>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label> <Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker <DateTimePicker
@@ -236,7 +236,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
</div> </div>
</ScrollArea> </ScrollArea>
<DialogFooter> <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button onClick={handleReset} variant='outline' type='button'> <Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' /> <RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')} {t('Reset')}
@@ -47,10 +47,10 @@ export function AnnouncementsPanel() {
loading={loading} loading={loading}
empty={!list.length} empty={!list.length}
emptyMessage={t('No announcements at this time')} emptyMessage={t('No announcements at this time')}
height='h-64' height='h-56 sm:h-64'
> >
<ScrollArea className='h-64'> <ScrollArea className='h-56 sm:h-64'>
<div className='-mx-4 sm:-mx-5'> <div className='-mx-3 sm:-mx-5'>
{list.map((item: AnnouncementItem, idx: number) => { {list.map((item: AnnouncementItem, idx: number) => {
const key = item.id ?? `announcement-${idx}` const key = item.id ?? `announcement-${idx}`
return ( return (
@@ -59,7 +59,7 @@ export function AnnouncementsPanel() {
type='button' type='button'
onClick={() => handleAnnouncementClick(item)} onClick={() => handleAnnouncementClick(item)}
className={cn( 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' idx < list.length - 1 && 'border-border/60 border-b'
)} )}
> >
@@ -23,8 +23,8 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
const status = props.status const status = props.status
return ( 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='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-3'> <div className='flex min-w-0 flex-1 items-center gap-2 sm:gap-3'>
<span <span
className={cn( className={cn(
'inline-block size-2 shrink-0 rounded-full', 'inline-block size-2 shrink-0 rounded-full',
@@ -91,7 +91,7 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
variant='ghost' variant='ghost'
size='sm' size='sm'
onClick={() => openExternalSpeedTest(item.url)} onClick={() => openExternalSpeedTest(item.url)}
className='size-7 p-0' className='hidden size-7 p-0 sm:inline-flex'
title={t('External Speed Test')} title={t('External Speed Test')}
> >
<Gauge className='size-3.5' /> <Gauge className='size-3.5' />
@@ -111,7 +111,7 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
variant='ghost' variant='ghost'
size='sm' size='sm'
asChild asChild
className='size-7 p-0' className='hidden size-7 p-0 sm:inline-flex'
title={t('Open in New Tab')} title={t('Open in New Tab')}
> >
<a href={item.url} target='_blank' rel='noreferrer'> <a href={item.url} target='_blank' rel='noreferrer'>
@@ -37,10 +37,10 @@ export function ApiInfoPanel() {
loading={loading} loading={loading}
empty={!list.length} empty={!list.length}
emptyMessage={t('No API routes configured')} emptyMessage={t('No API routes configured')}
height='h-64' height='h-56 sm:h-64'
> >
<ScrollArea className='h-64'> <ScrollArea className='h-56 sm:h-64'>
<div className='-mx-4 sm:-mx-5'> <div className='-mx-3 sm:-mx-5'>
{list.map((item: ApiInfoItem, idx: number) => ( {list.map((item: ApiInfoItem, idx: number) => (
<div <div
key={item.url} key={item.url}
@@ -27,9 +27,9 @@ export function FAQPanel() {
loading={loading} loading={loading}
empty={!list.length} empty={!list.length}
emptyMessage={t('No FAQ entries available')} 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'> <Accordion type='single' collapsible className='w-full'>
{list.map((item: FAQItem, idx: number) => { {list.map((item: FAQItem, idx: number) => {
const key = item.id ?? `faq-${idx}` const key = item.id ?? `faq-${idx}`
@@ -53,14 +53,9 @@ export function SummaryCards() {
return ( return (
<div className='overflow-hidden rounded-lg border'> <div className='overflow-hidden rounded-lg border'>
<StaggerContainer className='grid sm:grid-cols-2 lg:grid-cols-3'> <StaggerContainer className='divide-border/60 grid grid-cols-3 divide-x'>
{items.map((it, idx) => ( {items.map((it) => (
<StaggerItem <StaggerItem key={it.title} className='px-3 py-3 sm:px-5 sm:py-4'>
key={it.title}
className={`px-4 sm:px-5 ${
idx > 0 ? 'border-t sm:border-t-0 sm:border-l' : ''
}`}
>
<StatCard <StatCard
title={it.title} title={it.title}
value={it.value} value={it.value}
@@ -72,7 +67,7 @@ export function SummaryCards() {
<Button <Button
variant='outline' variant='outline'
size='sm' 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 asChild
> >
<Link to='/wallet'> <Link to='/wallet'>
@@ -84,7 +84,7 @@ export function UptimePanel() {
loading={loading} loading={loading}
empty={!groups.length} empty={!groups.length}
emptyMessage={t('No uptime monitoring configured')} emptyMessage={t('No uptime monitoring configured')}
height='h-80' height='h-64 sm:h-80'
headerActions={ headerActions={
<Button <Button
variant='ghost' variant='ghost'
@@ -100,11 +100,11 @@ export function UptimePanel() {
</Button> </Button>
} }
> >
<ScrollArea className='h-80'> <ScrollArea className='h-64 sm:h-80'>
<div className='-mx-4 space-y-0 sm:-mx-5'> <div className='-mx-3 space-y-0 sm:-mx-5'>
{groups.map((group, groupIdx) => ( {groups.map((group, groupIdx) => (
<div key={group.categoryName}> <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'> <div className='flex items-center gap-2'>
<h4 className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'> <h4 className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
{group.categoryName} {group.categoryName}
@@ -120,7 +120,7 @@ export function UptimePanel() {
<div <div
key={monitor.name} key={monitor.name}
className={cn( 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 && monitorIdx < (group.monitors?.length || 0) - 1 &&
'border-border/40 border-b', 'border-border/40 border-b',
groupIdx < groups.length - 1 && groupIdx < groups.length - 1 &&
@@ -20,10 +20,10 @@ export function PanelWrapper(props: PanelWrapperProps) {
if (props.loading) { if (props.loading) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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 className='text-sm font-semibold'>{props.title}</div>
</div> </div>
<div className='p-4 sm:p-5'> <div className='p-3 sm:p-5'>
<Skeleton className={`w-full ${height}`} /> <Skeleton className={`w-full ${height}`} />
</div> </div>
</div> </div>
@@ -33,7 +33,7 @@ export function PanelWrapper(props: PanelWrapperProps) {
if (props.empty) { if (props.empty) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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 className='text-sm font-semibold'>{props.title}</div>
</div> </div>
<div <div
@@ -47,9 +47,9 @@ export function PanelWrapper(props: PanelWrapperProps) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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 ? ( {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> <div className='text-sm font-semibold'>{props.title}</div>
{props.headerActions} {props.headerActions}
</div> </div>
@@ -57,7 +57,7 @@ export function PanelWrapper(props: PanelWrapperProps) {
<div className='text-sm font-semibold'>{props.title}</div> <div className='text-sm font-semibold'>{props.title}</div>
)} )}
</div> </div>
<div className='p-4 sm:p-5'>{props.children}</div> <div className='p-3 sm:p-5'>{props.children}</div>
</div> </div>
) )
} }
@@ -15,13 +15,15 @@ export function StatCard(props: StatCardProps) {
const Icon = props.icon const Icon = props.icon
return ( return (
<div className='group flex flex-col gap-1.5 py-3'> <div className='group flex flex-col gap-1'>
<div className='flex items-center justify-between'> <div className='flex items-start justify-between gap-1'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'> <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' /> <Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
{props.title} <span className='line-clamp-2 leading-snug'>{props.title}</span>
</div> </div>
{props.action} {props.action && (
<div className='shrink-0'>{props.action}</div>
)}
</div> </div>
{props.loading ? ( {props.loading ? (
@@ -31,19 +33,19 @@ export function StatCard(props: StatCardProps) {
</div> </div>
) : props.error ? ( ) : 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> </div>
<p className='text-muted-foreground/60 text-xs'> <p className='text-muted-foreground/60 hidden text-xs md:block'>
{props.description} {props.description}
</p> </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} {props.value}
</div> </div>
<p className='text-muted-foreground/60 text-xs'> <p className='text-muted-foreground/60 hidden text-xs md:block'>
{props.description} {props.description}
</p> </p>
</> </>
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart' import { VChart } from '@visactor/react-vchart'
import { Users, Loader2 } from 'lucide-react' import { Users, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' 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 { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider' import { useTheme } from '@/context/theme-provider'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
@@ -60,7 +60,7 @@ export function UserCharts() {
const [topUserLimit, setTopUserLimit] = useState(10) const [topUserLimit, setTopUserLimit] = useState(10)
const [timeRange, setTimeRange] = useState(() => { const [timeRange, setTimeRange] = useState(() => {
const days = getDefaultDays(timeGranularity) const days = getDefaultDays(timeGranularity)
const { start, end } = getNormalizedDateRange(days) const { start, end } = getRollingDateRange(days)
return { return {
start_timestamp: Math.floor(start.getTime() / 1000), start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000), end_timestamp: Math.floor(end.getTime() / 1000),
@@ -69,7 +69,7 @@ export function UserCharts() {
const handleRangeChange = useCallback((days: number) => { const handleRangeChange = useCallback((days: number) => {
setSelectedRange(days) setSelectedRange(days)
const { start, end } = getNormalizedDateRange(days) const { start, end } = getRollingDateRange(days)
setTimeRange({ setTimeRange({
start_timestamp: Math.floor(start.getTime() / 1000), start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000), end_timestamp: Math.floor(end.getTime() / 1000),
@@ -123,10 +123,9 @@ export function UserCharts() {
) )
return ( return (
<div className='space-y-4'> <div className='space-y-3'>
{/* Toolbar: time range presets + granularity */} <div className='flex items-center gap-1.5 overflow-x-auto pb-1 sm:gap-2'>
<div className='flex flex-wrap items-center gap-2'> <div className='flex shrink-0 items-center gap-1.5 rounded-md border p-0.5'>
<div className='flex items-center gap-1.5 rounded-md border p-0.5'>
{TIME_RANGE_PRESETS.map((preset) => ( {TIME_RANGE_PRESETS.map((preset) => (
<button <button
key={preset.days} key={preset.days}
@@ -143,7 +142,7 @@ export function UserCharts() {
))} ))}
</div> </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) => ( {TIME_GRANULARITY_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -162,7 +161,7 @@ export function UserCharts() {
))} ))}
</div> </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'> <span className='text-muted-foreground px-2 text-xs font-medium'>
{t('Top Users')} {t('Top Users')}
</span> </span>
@@ -187,7 +186,7 @@ export function UserCharts() {
)} )}
</div> </div>
<div className='grid gap-4'> <div className='grid gap-3'>
{USER_CHARTS.map((chart) => { {USER_CHARTS.map((chart) => {
const spec = chartData[chart.specKey] const spec = chartData[chart.specKey]
@@ -196,12 +195,12 @@ export function UserCharts() {
key={chart.value} key={chart.value}
className='overflow-hidden rounded-lg border' 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' /> <Users className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>{t(chart.labelKey)}</div> <div className='text-sm font-semibold'>{t(chart.labelKey)}</div>
</div> </div>
<div className='h-96 p-2'> <div className='h-[300px] p-1.5 sm:h-96 sm:p-2'>
{isLoading ? ( {isLoading ? (
<Skeleton className='h-full w-full' /> <Skeleton className='h-full w-full' />
) : ( ) : (
@@ -76,9 +76,7 @@ export function useSummaryCardsConfig(totals: {
return [ return [
{ {
key: 'balance', key: 'balance',
title: totals.currencyEnabled title: t('Current Balance'),
? `${t('Current Balance')} (${totals.currencyLabel})`
: t('Current Balance'),
value: totals.remainDisplay, value: totals.remainDisplay,
description: totals.currencyEnabled description: totals.currencyEnabled
? `${t('Remaining quota')} (${totals.currencyLabel})` ? `${t('Remaining quota')} (${totals.currencyLabel})`
@@ -87,9 +85,7 @@ export function useSummaryCardsConfig(totals: {
}, },
{ {
key: 'usage', key: 'usage',
title: totals.currencyEnabled title: t('Historical Usage'),
? `${t('Historical Usage')} (${totals.currencyLabel})`
: t('Historical Usage'),
value: totals.usedDisplay, value: totals.usedDisplay,
description: totals.currencyEnabled description: totals.currencyEnabled
? `${t('Total consumed')} (${totals.currencyLabel})` ? `${t('Total consumed')} (${totals.currencyLabel})`
+4 -4
View File
@@ -191,9 +191,9 @@ export function Dashboard() {
{t(meta.descriptionKey)} {t(meta.descriptionKey)}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
{activeSection !== 'overview' && ( {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 ? ( {showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}> <Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'> <TabsList className='h-auto max-w-full flex-wrap justify-start'>
@@ -208,7 +208,7 @@ export function Dashboard() {
<div /> <div />
)} )}
{modelActions != null && ( {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} {modelActions}
</div> </div>
)} )}
@@ -217,7 +217,7 @@ export function Dashboard() {
{activeSection === 'overview' && ( {activeSection === 'overview' && (
<> <>
<SummaryCards /> <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> <CardStaggerItem>
<ApiInfoPanel /> <ApiInfoPanel />
</CardStaggerItem> </CardStaggerItem>
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TimeGranularity } from '@/lib/time' import type { TimeGranularity } from '@/lib/time'
import { getNormalizedDateRange } from '@/lib/time' import { getRollingDateRange } from '@/lib/time'
import { import {
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY, DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
DEFAULT_DASHBOARD_CHART_PREFERENCES, DEFAULT_DASHBOARD_CHART_PREFERENCES,
@@ -128,7 +128,7 @@ export function getDefaultDays(granularity?: TimeGranularity): number {
export function buildDefaultDashboardFilters( export function buildDefaultDashboardFilters(
preferences: DashboardChartPreferences = getSavedChartPreferences() preferences: DashboardChartPreferences = getSavedChartPreferences()
): DashboardFilters { ): DashboardFilters {
const { start, end } = getNormalizedDateRange(preferences.defaultTimeRangeDays) const { start, end } = getRollingDateRange(preferences.defaultTimeRangeDays)
return { return {
...EMPTY_DASHBOARD_FILTERS, ...EMPTY_DASHBOARD_FILTERS,
start_timestamp: start, start_timestamp: start,
@@ -62,7 +62,13 @@ function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
if (!label) return null if (!label) return null
return ( 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} {label}
</Badge> </Badge>
) )
@@ -110,20 +116,22 @@ export function ApiKeyGroupCombobox({
role='combobox' role='combobox'
aria-expanded={open} aria-expanded={open}
disabled={disabled} 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='min-w-0'>
<span className='block truncate font-medium'> <span className='block truncate font-medium'>
{selectedOption?.value || placeholder || t('Select a group')} {selectedOption?.value || placeholder || t('Select a group')}
</span> </span>
{selectedOption?.desc && ( {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} {selectedOption.desc}
</span> </span>
)} )}
</span> </span>
<GroupRatioBadge ratio={selectedOption?.ratio} /> <span className='hidden sm:block'>
<GroupRatioBadge ratio={selectedOption?.ratio} />
</span>
</span> </span>
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' /> <ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button> </Button>
@@ -79,18 +79,18 @@ function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
return ( return (
<section className='bg-card rounded-lg border'> <section className='bg-card rounded-lg border'>
<div className='flex items-center gap-3 border-b px-4 py-3'> <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-10 shrink-0 items-center justify-center rounded-lg border'> <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-5' /> <Icon className='size-4 sm:size-5' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
<h3 className='text-sm font-medium leading-none'>{props.title}</h3> <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} {props.description}
</p> </p>
</div> </div>
</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> </section>
) )
} }
@@ -254,13 +254,13 @@ export function ApiKeysMutateDrawer({
> >
<SheetContent <SheetContent
side={side} 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'> <SheetHeader className='bg-background border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
<SheetTitle className='text-lg'> <SheetTitle className='text-base sm:text-lg'>
{isUpdate ? t('Update API Key') : t('Create API Key')} {isUpdate ? t('Update API Key') : t('Create API Key')}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription className='pr-6 text-xs sm:text-sm'>
{isUpdate {isUpdate
? t('Update the API key by providing necessary info.') ? t('Update the API key by providing necessary info.')
: t('Add a new API key by providing necessary info.')}{' '} : t('Add a new API key by providing necessary info.')}{' '}
@@ -271,7 +271,7 @@ export function ApiKeysMutateDrawer({
<form <form
id='api-key-form' id='api-key-form'
onSubmit={form.handleSubmit(onSubmit)} 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 <ApiKeyFormSection
title={t('Basic Information')} title={t('Basic Information')}
@@ -319,12 +319,12 @@ export function ApiKeysMutateDrawer({
control={form.control} control={form.control}
name='cross_group_retry' name='cross_group_retry'
render={({ field }) => ( 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'> <div className='space-y-0.5'>
<FormLabel className='text-sm'> <FormLabel className='text-sm'>
{t('Cross-group retry')} {t('Cross-group retry')}
</FormLabel> </FormLabel>
<FormDescription className='text-xs'> <FormDescription className='line-clamp-2 text-xs sm:line-clamp-none'>
{t( {t(
'When enabled, if channels in the current group fail, it will try channels in the next group in order.' '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} value={field.value}
onChange={field.onChange} onChange={field.onChange}
placeholder={t('Never expires')} placeholder={t('Never expires')}
className='min-w-0' className='min-w-0 [&_input[type=time]]:w-24 sm:[&_input[type=time]]:w-32'
/> />
</FormControl> </FormControl>
<div className='grid grid-cols-4 gap-2 sm:flex'> <div className='grid grid-cols-4 gap-2 sm:flex'>
@@ -361,7 +361,7 @@ export function ApiKeysMutateDrawer({
type='button' type='button'
variant='outline' variant='outline'
size='sm' size='sm'
className='px-3' className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 0)} onClick={() => handleSetExpiry(0, 0, 0)}
> >
{t('Never')} {t('Never')}
@@ -370,7 +370,7 @@ export function ApiKeysMutateDrawer({
type='button' type='button'
variant='outline' variant='outline'
size='sm' size='sm'
className='px-3' className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(1, 0, 0)} onClick={() => handleSetExpiry(1, 0, 0)}
> >
{t('1 Month')} {t('1 Month')}
@@ -379,7 +379,7 @@ export function ApiKeysMutateDrawer({
type='button' type='button'
variant='outline' variant='outline'
size='sm' size='sm'
className='px-3' className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 1, 0)} onClick={() => handleSetExpiry(0, 1, 0)}
> >
{t('1 Day')} {t('1 Day')}
@@ -388,7 +388,7 @@ export function ApiKeysMutateDrawer({
type='button' type='button'
variant='outline' variant='outline'
size='sm' size='sm'
className='px-3' className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 1)} onClick={() => handleSetExpiry(0, 0, 1)}
> >
{t('1 Hour')} {t('1 Hour')}
@@ -470,7 +470,7 @@ export function ApiKeysMutateDrawer({
control={form.control} control={form.control}
name='unlimited_quota' name='unlimited_quota'
render={({ field }) => ( 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'> <div className='space-y-0.5'>
<FormLabel className='text-sm'> <FormLabel className='text-sm'>
{t('Unlimited Quota')} {t('Unlimited Quota')}
@@ -495,10 +495,10 @@ export function ApiKeysMutateDrawer({
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type='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'> <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-5' /> <Settings2 className='size-4 sm:size-5' />
</div> </div>
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<h3 className='text-sm font-medium leading-none'> <h3 className='text-sm font-medium leading-none'>
@@ -517,7 +517,7 @@ export function ApiKeysMutateDrawer({
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <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 <FormField
control={form.control} control={form.control}
name='model_limits' name='model_limits'
@@ -578,11 +578,18 @@ export function ApiKeysMutateDrawer({
</Collapsible> </Collapsible>
</form> </form>
</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> <SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button> <Button variant='outline' className='w-full sm:w-auto'>
{t('Close')}
</Button>
</SheetClose> </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')} {isSubmitting ? t('Saving...') : t('Save changes')}
</Button> </Button>
</SheetFooter> </SheetFooter>
+138 -13
View File
@@ -16,7 +16,9 @@ import {
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Database } from 'lucide-react'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
import { import {
Table, Table,
@@ -33,16 +35,31 @@ import {
DataTableToolbar, DataTableToolbar,
TableSkeleton, TableSkeleton,
TableEmpty, TableEmpty,
MobileCardList,
} from '@/components/data-table' } from '@/components/data-table'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { PageFooterPortal } from '@/components/layout' import { PageFooterPortal } from '@/components/layout'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusBadge } from '@/components/status-badge'
import { getApiKeys, searchApiKeys } from '../api' 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 { type ApiKey } from '../types'
import { ApiKeyCell } from './api-keys-cells'
import { useApiKeysColumns } from './api-keys-columns' import { useApiKeysColumns } from './api-keys-columns'
import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons' import { ApiKeysPrimaryButtons } from './api-keys-primary-buttons'
import { useApiKeys } from './api-keys-provider' import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions' import { DataTableBulkActions } from './data-table-bulk-actions'
import { DataTableRowActions } from './data-table-row-actions'
const route = getRouteApi('/_authenticated/keys/') const route = getRouteApi('/_authenticated/keys/')
@@ -50,6 +67,123 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
return apiKey.status !== API_KEY_STATUS.ENABLED 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() { export function ApiKeysTable() {
const { t } = useTranslation() const { t } = useTranslation()
const { refreshTrigger } = useApiKeys() const { refreshTrigger } = useApiKeys()
@@ -166,7 +300,7 @@ export function ApiKeysTable() {
return ( 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'> <div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<ApiKeysPrimaryButtons /> <ApiKeysPrimaryButtons />
<div className='min-w-0 sm:flex sm:justify-end'> <div className='min-w-0 sm:flex sm:justify-end'>
@@ -184,18 +318,9 @@ export function ApiKeysTable() {
</div> </div>
</div> </div>
{isMobile ? ( {isMobile ? (
<MobileCardList <ApiKeysMobileList
table={table} table={table}
isLoading={isLoading} 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 <div
@@ -72,7 +72,7 @@ export function DeploymentsTable() {
pageKey: 'dPage', pageKey: 'dPage',
pageSizeKey: 'dPageSize', pageSizeKey: 'dPageSize',
defaultPage: 1, defaultPage: 1,
defaultPageSize: 10, defaultPageSize: isMobile ? 8 : 10,
}, },
globalFilter: { enabled: true, key: 'dFilter' }, globalFilter: { enabled: true, key: 'dFilter' },
columnFilters: [ columnFilters: [
@@ -229,7 +229,7 @@ export function DeploymentsTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<DataTableToolbar <DataTableToolbar
table={table} table={table}
searchPlaceholder={t('Search deployments...')} searchPlaceholder={t('Search deployments...')}
@@ -195,7 +195,7 @@ export function UpdateConfigDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -205,14 +205,14 @@ export function UpdateConfigDialog({
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' /> <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div> </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 {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off' autoComplete='off'
className='space-y-4' 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 <FormField
control={form.control} control={form.control}
name='image_url' name='image_url'
@@ -262,7 +262,7 @@ export function UpdateConfigDialog({
/> />
</div> </div>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField <FormField
control={form.control} control={form.control}
name='entrypoint' name='entrypoint'
@@ -313,7 +313,7 @@ export function UpdateConfigDialog({
{t('Registry (optional)')} {t('Registry (optional)')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <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 <FormField
control={form.control} control={form.control}
name='registry_username' name='registry_username'
@@ -353,7 +353,7 @@ export function UpdateConfigDialog({
{t('Environment variables')} {t('Environment variables')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <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 <FormField
control={form.control} control={form.control}
name='env_json' name='env_json'
@@ -394,7 +394,7 @@ export function UpdateConfigDialog({
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<DialogFooter className='pt-2'> <DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -99,18 +99,18 @@ export function ViewDetailsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle>{t('Deployment details')}</DialogTitle> <DialogTitle>{t('Deployment details')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className='max-h-[72vh] space-y-4 overflow-y-auto py-2 pr-1'> <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='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '} {t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span> <span className='font-mono'>{deploymentId}</span>
</div> </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}> <Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' /> <Copy className='mr-2 h-4 w-4' />
{t('Copy')} {t('Copy')}
@@ -252,7 +252,7 @@ export function ViewDetailsDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)} className='w-full sm:w-auto'>
{t('Close')} {t('Close')}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -124,7 +124,7 @@ export function ViewLogsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className='flex items-center gap-2'> <DialogTitle className='flex items-center gap-2'>
<Terminal className='h-5 w-5' /> <Terminal className='h-5 w-5' />
@@ -132,11 +132,11 @@ export function ViewLogsDialog({
</DialogTitle> </DialogTitle>
</DialogHeader> </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'> <div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId} {t('Deployment ID')}: {deploymentId}
</div> </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 <Button
variant='outline' variant='outline'
size='sm' size='sm'
@@ -162,14 +162,14 @@ export function ViewLogsDialog({
<Download className='mr-2 h-4 w-4' /> <Download className='mr-2 h-4 w-4' />
{t('Download')} {t('Download')}
</Button> </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> <span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} /> <Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div> </div>
</div> </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='space-y-1'>
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground text-xs'>
{t('Container')} {t('Container')}
@@ -234,7 +234,7 @@ export function ViewLogsDialog({
<div <div
ref={scrollRef} 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) => { onScroll={(e) => {
const target = e.target as HTMLDivElement const target = e.target as HTMLDivElement
const isAtBottom = const isAtBottom =
@@ -601,8 +601,8 @@ export function ModelMutateDrawer({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex w-full flex-col sm:max-w-2xl'> <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
<SheetHeader className='text-start'> <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle> <SheetTitle>
{isEditing ? t('Edit Model') : t('Create Model')} {isEditing ? t('Edit Model') : t('Create Model')}
</SheetTitle> </SheetTitle>
@@ -621,7 +621,7 @@ export function ModelMutateDrawer({
onSubmit={form.handleSubmit( onSubmit={form.handleSubmit(
onSubmit as Parameters<typeof form.handleSubmit>[0] 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 */} {/* Basic Information */}
<div className='space-y-4'> <div className='space-y-4'>
@@ -1232,7 +1232,7 @@ export function ModelMutateDrawer({
</form> </form>
</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> <SheetClose asChild>
<Button variant='outline' disabled={isSubmitting}> <Button variant='outline' disabled={isSubmitting}>
{t('Cancel')} {t('Cancel')}
@@ -161,8 +161,8 @@ export function PrefillGroupFormDrawer({
return ( return (
<Sheet open={open} onOpenChange={handleOpenChange}> <Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className='flex w-full flex-col sm:max-w-2xl'> <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
<SheetHeader className='text-start'> <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle> <SheetTitle>
{isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')} {isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')}
</SheetTitle> </SheetTitle>
@@ -177,7 +177,7 @@ export function PrefillGroupFormDrawer({
<form <form
id='prefill-group-form' id='prefill-group-form'
onSubmit={form.handleSubmit(handleSubmit)} 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-4'>
<div className='space-y-1'> <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'> <div className='flex items-center gap-2'>
<h4 className='text-sm font-medium'>{t('Project')}</h4> <h4 className='text-sm font-medium'>{t('Project')}</h4>
<StatusBadge <StatusBadge
@@ -343,7 +343,7 @@ export function PrefillGroupFormDrawer({
</form> </form>
</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> <SheetClose asChild>
<Button type='button' variant='outline' disabled={isSaving}> <Button type='button' variant='outline' disabled={isSaving}>
{t('Cancel')} {t('Cancel')}
@@ -67,7 +67,10 @@ export function ModelsTable() {
} = useTableUrlState({ } = useTableUrlState({
search: route.useSearch(), search: route.useSearch(),
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE }, pagination: {
defaultPage: 1,
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
},
globalFilter: { enabled: true, key: 'filter' }, globalFilter: { enabled: true, key: 'filter' },
columnFilters: [ columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' }, { columnId: 'status', searchKey: 'status', type: 'array' },
@@ -217,7 +220,7 @@ export function ModelsTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<DataTableToolbar <DataTableToolbar
table={table} table={table}
searchPlaceholder={t('Filter by model name...')} searchPlaceholder={t('Filter by model name...')}
@@ -206,8 +206,8 @@ export function DynamicPricingBreakdown({
}) })
return ( return (
<section className='min-w-0 py-4'> <section className='min-w-0 py-3 sm:py-4'>
<div className='mb-4 flex items-start gap-2'> <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'> <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' /> <TagIcon className='size-3.5' />
</span> </span>
@@ -222,11 +222,71 @@ export function DynamicPricingBreakdown({
</div> </div>
{hasTiers && ( {hasTiers && (
<div className='mb-4'> <div className='mb-3 sm:mb-4'>
<div className='text-foreground mb-2 text-sm font-semibold'> <div className='text-foreground mb-2 text-sm font-semibold'>
{t('Tiered price table')} {t('Tiered price table')}
</div> </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'> <Table className='text-sm'>
<TableHeader> <TableHeader>
<TableRow className='hover:bg-transparent'> <TableRow className='hover:bg-transparent'>
+6 -6
View File
@@ -620,16 +620,16 @@ export function FilterBar(props: FilterBarProps) {
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent <SheetContent
side='right' 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> <SheetTitle>{t('Filters')}</SheetTitle>
<SheetDescription className='sr-only'> <SheetDescription className='sr-only'>
{t('Filter models by type, endpoint, vendor, group and tags')} {t('Filter models by type, endpoint, vendor, group and tags')}
</SheetDescription> </SheetDescription>
</SheetHeader> </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 <MobileFilterGroup
title={t('Pricing Type')} title={t('Pricing Type')}
value={props.quotaTypeFilter} value={props.quotaTypeFilter}
@@ -671,7 +671,7 @@ export function FilterBar(props: FilterBarProps) {
<h3 className='text-foreground mb-3 text-sm font-semibold'> <h3 className='text-foreground mb-3 text-sm font-semibold'>
{t('Display Options')} {t('Display Options')}
</h3> </h3>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('Price display')} {t('Price display')}
@@ -704,8 +704,8 @@ export function FilterBar(props: FilterBarProps) {
</div> </div>
</div> </div>
<SheetFooter className='border-t px-6 py-4'> <SheetFooter className='border-t px-4 py-3 sm:px-6 sm:py-4'>
<div className='flex w-full gap-3'> <div className='grid w-full grid-cols-2 gap-2 sm:flex sm:gap-3'>
{props.hasActiveFilters && ( {props.hasActiveFilters && (
<Button <Button
variant='outline' variant='outline'
+3 -3
View File
@@ -68,7 +68,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
return ( return (
<div <div
className={cn( 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' 'hover:bg-muted/20'
)} )}
> >
@@ -175,12 +175,12 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
</div> </div>
{/* Description */} {/* 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.')} {props.model.description || t('No description available.')}
</p> </p>
{/* Footer row 1: group + billing type */} {/* 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 && ( {primaryGroup && (
<span className='text-muted-foreground text-xs font-medium'> <span className='text-muted-foreground text-xs font-medium'>
{primaryGroup} {t('Groups')} {primaryGroup} {t('Groups')}
@@ -783,13 +783,13 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side='right' 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'> <SheetHeader className='sr-only'>
<SheetTitle>{props.model.model_name}</SheetTitle> <SheetTitle>{props.model.model_name}</SheetTitle>
<SheetDescription>{t('Model details')}</SheetDescription> <SheetDescription>{t('Model details')}</SheetDescription>
</SheetHeader> </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} /> <ModelDetailsContent {...contentProps} />
</div> </div>
</SheetContent> </SheetContent>
@@ -259,15 +259,15 @@ export function PricingToolbar(props: PricingToolbarProps) {
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}> <Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
<SheetContent <SheetContent
side='right' 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> <SheetTitle>{t('Filter')}</SheetTitle>
<SheetDescription> <SheetDescription>
{t('Filter models by provider, group, type, endpoint, and tags.')} {t('Filter models by provider, group, type, endpoint, and tags.')}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 overflow-y-auto p-4'> <div className='flex-1 overflow-y-auto p-3 sm:p-4'>
<PricingSidebar <PricingSidebar
quotaTypeFilter={props.quotaTypeFilter} quotaTypeFilter={props.quotaTypeFilter}
endpointTypeFilter={props.endpointTypeFilter} endpointTypeFilter={props.endpointTypeFilter}
+5 -5
View File
@@ -129,7 +129,7 @@ export function Pricing() {
if (isLoading) { if (isLoading) {
return ( return (
<PublicLayout showMainContainer={false}> <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} /> <LoadingSkeleton viewMode={viewMode} />
</div> </div>
</PublicLayout> </PublicLayout>
@@ -152,15 +152,15 @@ export function Pricing() {
WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)', 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'> <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-8 max-w-3xl pt-8 text-center sm:mb-10 sm:pt-10'> <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'> <p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
{t('Models Directory')} {t('Models Directory')}
</p> </p>
<h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'> <h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
{t('Model Square')} {t('Model Square')}
</h1> </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', { {t('This site currently has {{count}} models enabled', {
count: models?.length || 0, count: models?.length || 0,
})} })}
@@ -175,7 +175,7 @@ export function Pricing() {
onChange={setSearchInput} onChange={setSearchInput}
onClear={clearSearch} onClear={clearSearch}
placeholder={t('Search model name, provider, endpoint, or tag...')} 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> </header>
+8
View File
@@ -41,6 +41,14 @@ export async function updateUserSettings(
return res.data 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 * Delete user account
*/ */
@@ -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>
)
}
+87 -75
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
@@ -169,14 +169,13 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
if (pageLoading || loading) { if (pageLoading || loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader> <CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' /> <Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' /> <Skeleton className='mt-2 h-4 w-64' />
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='p-3 sm:p-5'>
<Skeleton className='h-12 w-full' /> <Skeleton className='h-20 w-full' />
<Skeleton className='h-12 w-full' />
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -191,19 +190,20 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
return ( return (
<> <>
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader> <CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-xl tracking-tight'> <CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Passkey Login')} {t('Passkey Login')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className='text-xs sm:text-sm'>
{t('Use Passkey to sign in without entering your password.')} {t('Use Passkey to sign in without entering your password.')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='p-3 sm:p-5'>
<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='space-y-6'>
<div className='flex items-start gap-3'> <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'> <div className='bg-muted rounded-md p-2'>
<KeyRound className='h-5 w-5' /> <KeyRound className='h-5 w-5' />
</div> </div>
@@ -241,74 +241,86 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
{t('Last used:')} {formattedLastUsed} {t('Last used:')} {formattedLastUsed}
</p> </p>
</div> </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> </div>
{!enabled ? ( {enabled && (
<Button <div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
className='w-full sm:w-auto xl:w-full 2xl:w-auto' <AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
onClick={handleRegister} <AlertDialogTrigger asChild>
disabled={!supported || registering} <Button
> variant='destructive'
{registering && ( className='flex-1'
<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'
disabled={removing} disabled={removing}
onClick={(event) => {
event.preventDefault()
handleRemove()
}}
> >
{t('Remove')} {removing ? (
</AlertDialogAction> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
</AlertDialogFooter> ) : (
</AlertDialogContent> <AlertTriangle className='mr-2 h-4 w-4' />
</AlertDialog> )}
{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> </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> </CardContent>
</Card> </Card>
@@ -82,17 +82,17 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
return ( return (
<div className='bg-card overflow-hidden rounded-lg border'> <div className='bg-card overflow-hidden rounded-lg border'>
<div className='p-4 sm:p-5'> <div className='p-3 sm:p-5'>
<div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'> <div className='flex items-center gap-3 text-left sm:gap-4'>
<Avatar className='ring-background h-16 w-16 rounded-2xl text-lg ring-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-2xl'> <AvatarFallback className='bg-primary/10 text-primary rounded-xl sm:rounded-2xl'>
{initials} {initials}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className='min-w-0 flex-1 space-y-3'> <div className='min-w-0 flex-1 space-y-1.5 sm:space-y-3'>
<div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-start'> <div className='flex min-w-0 items-center gap-2'>
<h1 className='text-2xl font-semibold tracking-tight'> <h1 className='truncate text-xl font-semibold tracking-tight sm:text-2xl'>
{displayName} {displayName}
</h1> </h1>
<StatusBadge <StatusBadge
@@ -102,18 +102,18 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
/> />
</div> </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'> <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>@{profile.username}</span> <span className='truncate'>@{profile.username}</span>
{profile.email && ( {profile.email && (
<> <>
<span className='hidden sm:inline'></span> <span></span>
<span>{profile.email}</span> <span className='truncate'>{profile.email}</span>
</> </>
)} )}
{profile.group && ( {profile.group && (
<> <>
<span className='hidden sm:inline'></span> <span></span>
<span>{profile.group}</span> <span className='truncate'>{profile.group}</span>
</> </>
)} )}
</div> </div>
@@ -121,9 +121,9 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
</div> </div>
</div> </div>
<div className='border-t'> <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) => ( {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'> <div className='flex items-center gap-2'>
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' /> <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'> <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> </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} {item.value}
</div> </div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'> <div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
@@ -4,11 +4,10 @@ import { useDialogs } from '@/hooks/use-dialog'
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import type { UserProfile } from '../types' import type { UserProfile } from '../types'
import { AccessTokenDialog } from './dialogs/access-token-dialog' import { AccessTokenDialog } from './dialogs/access-token-dialog'
import { ChangePasswordDialog } from './dialogs/change-password-dialog' import { ChangePasswordDialog } from './dialogs/change-password-dialog'
@@ -34,12 +33,12 @@ export function ProfileSecurityCard({
if (loading) { if (loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b'> <CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-3 pt-6'> <CardContent className='space-y-3 p-3 sm:p-5'>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' /> <Skeleton key={i} className='h-16 w-full' />
))} ))}
@@ -76,31 +75,18 @@ export function ProfileSecurityCard({
return ( return (
<> <>
<Card className='overflow-hidden'> <TitledCard
<CardHeader className='border-b'> title={t('Security')}
<div className='flex items-center gap-3'> description={t('Manage your security settings and account access')}
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'> icon={<Shield className='h-4 w-4' />}
<Shield className='h-4 w-4' /> >
</div> <div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
<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'>
{securityActions.map((item) => ( {securityActions.map((item) => (
<button <button
key={item.title} key={item.title}
type='button' type='button'
onClick={item.action} 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' item.variant === 'destructive'
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5' ? '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' /> <item.icon className='h-5 w-5' />
</div> </div>
<p className='text-sm font-medium'>{item.title}</p> <div className='min-w-0 md:contents'>
<p className='text-muted-foreground text-xs'> <p className='text-sm font-medium'>{item.title}</p>
{item.description} <p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
</p> {item.description}
</p>
</div>
</button> </button>
))} ))}
</div> </div>
</CardContent> </TitledCard>
</Card>
{/* Dialogs */} {/* Dialogs */}
<ChangePasswordDialog <ChangePasswordDialog
@@ -4,12 +4,11 @@ import { useTranslation } from 'react-i18next'
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TitledCard } from '@/components/ui/titled-card'
import type { UserProfile } from '../types' import type { UserProfile } from '../types'
import { AccountBindingsTab } from './tabs/account-bindings-tab' import { AccountBindingsTab } from './tabs/account-bindings-tab'
import { NotificationTab } from './tabs/notification-tab' import { NotificationTab } from './tabs/notification-tab'
@@ -34,12 +33,12 @@ export function ProfileSettingsCard({
if (loading) { if (loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b'> <CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-4 pt-6'> <CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-10 w-full' /> <Skeleton className='h-10 w-full' />
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-20 w-full' /> <Skeleton key={i} className='h-20 w-full' />
@@ -50,29 +49,16 @@ export function ProfileSettingsCard({
} }
return ( return (
<Card className='overflow-hidden'> <TitledCard
<CardHeader className='border-b'> title={t('Settings')}
<div className='flex items-center gap-3'> description={t('Configure your account preferences and integrations')}
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'> icon={<Settings className='h-4 w-4' />}
<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'>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'> <TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
<TabsTrigger <TabsTrigger
value='bindings' 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' /> <Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span> <span className='hidden sm:inline'>{t('Account Bindings')}</span>
@@ -80,7 +66,7 @@ export function ProfileSettingsCard({
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='settings' 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' /> <Settings className='h-4 w-4' />
<span className='hidden sm:inline'> <span className='hidden sm:inline'>
@@ -90,15 +76,14 @@ export function ProfileSettingsCard({
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='bindings' className='mt-6'> <TabsContent value='bindings' className='mt-4 sm:mt-6'>
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} /> <AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent> </TabsContent>
<TabsContent value='settings' className='mt-6'> <TabsContent value='settings' className='mt-4 sm:mt-6'>
<NotificationTab profile={profile} onUpdate={onProfileUpdate} /> <NotificationTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</CardContent> </TitledCard>
</Card>
) )
} }
@@ -182,23 +182,23 @@ export function SidebarModulesCard() {
} }
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b'> <CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<div className='flex items-center gap-3'> <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' /> <LayoutDashboard className='h-4 w-4' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'> <CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Sidebar Personal Settings')} {t('Sidebar Personal Settings')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className='text-xs sm:text-sm'>
{t('Customize sidebar display content')} {t('Customize sidebar display content')}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
</CardHeader> </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) => { {sectionDefs.map((section) => {
const sectionEnabled = config[section.key]?.enabled !== false const sectionEnabled = config[section.key]?.enabled !== false
return ( return (
@@ -245,14 +245,14 @@ export function AccountBindingsTab({
return ( 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) => ( {bindings.map((binding) => (
<div <div
key={binding.id} 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='flex min-w-0 items-center gap-2.5 sm:gap-3'>
<div className='bg-muted shrink-0 rounded-md p-2'> <div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
<binding.icon className='h-4 w-4' /> <binding.icon className='h-4 w-4' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
@@ -274,7 +274,7 @@ export function AccountBindingsTab({
<Button <Button
variant='outline' variant='outline'
size='sm' 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} onClick={binding.onBind}
disabled={binding.isBound && binding.id !== 'email'} disabled={binding.isBound && binding.id !== 'email'}
> >
@@ -295,7 +295,7 @@ export function AccountBindingsTab({
<p className='text-muted-foreground mb-3 text-sm font-medium'> <p className='text-muted-foreground mb-3 text-sm font-medium'>
{t('Custom OAuth')} {t('Custom OAuth')}
</p> </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) => { {customProviders.map((provider) => {
const binding = customBindings.find( const binding = customBindings.find(
(b) => b.provider_id === provider.id (b) => b.provider_id === provider.id
@@ -304,10 +304,10 @@ export function AccountBindingsTab({
return ( return (
<div <div
key={provider.id} 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='flex min-w-0 items-center gap-2.5 sm:gap-3'>
<div className='bg-muted shrink-0 rounded-md p-2'> <div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
<Link2 className='h-4 w-4' /> <Link2 className='h-4 w-4' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
@@ -332,7 +332,7 @@ export function AccountBindingsTab({
<Button <Button
variant='ghost' variant='ghost'
size='sm' 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)} onClick={() => setUnbindTarget(binding)}
> >
<Unlink className='mr-1 h-3 w-3' /> <Unlink className='mr-1 h-3 w-3' />
@@ -342,7 +342,7 @@ export function AccountBindingsTab({
<Button <Button
variant='outline' variant='outline'
size='sm' 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)} onClick={() => handleBindCustomOAuth(provider)}
> >
{t('Bind')} {t('Bind')}
@@ -102,16 +102,16 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
} }
return ( return (
<div className='space-y-6'> <div className='space-y-4 sm:space-y-6'>
{/* Notification Type */} {/* Notification Type */}
<div className='space-y-3'> <div className='space-y-2.5'>
<Label>{t('Notification Method')}</Label> <Label>{t('Notification Method')}</Label>
<RadioGroup <RadioGroup
value={settings.notify_type} value={settings.notify_type}
onValueChange={(value) => onValueChange={(value) =>
updateField('notify_type', value as NotifyType) 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) => { {NOTIFICATION_METHODS.map((method) => {
const Icon = NOTIFICATION_ICONS[method.value] const Icon = NOTIFICATION_ICONS[method.value]
@@ -120,7 +120,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Label <Label
key={method.value} key={method.value}
htmlFor={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 isSelected
? 'border-primary bg-primary/5 text-primary' ? 'border-primary bg-primary/5 text-primary'
: 'border-muted hover:border-muted-foreground/25 hover:bg-muted/50' : 'border-muted hover:border-muted-foreground/25 hover:bg-muted/50'
@@ -131,8 +131,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
id={method.value} id={method.value}
className='sr-only' className='sr-only'
/> />
<Icon className='h-5 w-5' /> <Icon className='h-4 w-4 sm:h-5 sm:w-5' />
<span className='text-sm font-medium'>{t(method.label)}</span> <span className='max-w-full truncate text-xs font-medium sm:text-sm'>
{t(method.label)}
</span>
</Label> </Label>
) )
})} })}
@@ -140,11 +142,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div> </div>
{/* Warning Threshold */} {/* Warning Threshold */}
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='threshold'>{t('Quota Warning Threshold')}</Label> <Label htmlFor='threshold'>{t('Quota Warning Threshold')}</Label>
<Input <Input
id='threshold' id='threshold'
type='number' type='number'
className='h-9'
value={settings.quota_warning_threshold} value={settings.quota_warning_threshold}
onChange={(e) => onChange={(e) =>
updateField('quota_warning_threshold', Number(e.target.value)) updateField('quota_warning_threshold', Number(e.target.value))
@@ -158,11 +161,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
{/* Email Settings */} {/* Email Settings */}
{settings.notify_type === 'email' && ( {settings.notify_type === 'email' && (
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='notifyEmail'>{t('Notification Email')}</Label> <Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
<Input <Input
id='notifyEmail' id='notifyEmail'
type='email' type='email'
className='h-9'
value={settings.notification_email} value={settings.notification_email}
onChange={(e) => updateField('notification_email', e.target.value)} onChange={(e) => updateField('notification_email', e.target.value)}
placeholder={t('Leave empty to use account email')} placeholder={t('Leave empty to use account email')}
@@ -173,17 +177,18 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
{/* Webhook Settings */} {/* Webhook Settings */}
{settings.notify_type === 'webhook' && ( {settings.notify_type === 'webhook' && (
<> <>
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label> <Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
<Input <Input
id='webhookUrl' id='webhookUrl'
type='url' type='url'
className='h-9'
value={settings.webhook_url} value={settings.webhook_url}
onChange={(e) => updateField('webhook_url', e.target.value)} onChange={(e) => updateField('webhook_url', e.target.value)}
placeholder={t('https://example.com/webhook')} placeholder={t('https://example.com/webhook')}
/> />
</div> </div>
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='webhookSecret'>{t('Webhook Secret')}</Label> <Label htmlFor='webhookSecret'>{t('Webhook Secret')}</Label>
<PasswordInput <PasswordInput
id='webhookSecret' id='webhookSecret'
@@ -197,11 +202,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
{/* Bark Settings */} {/* Bark Settings */}
{settings.notify_type === 'bark' && ( {settings.notify_type === 'bark' && (
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label> <Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
<Input <Input
id='barkUrl' id='barkUrl'
type='url' type='url'
className='h-9'
value={settings.bark_url} value={settings.bark_url}
onChange={(e) => updateField('bark_url', e.target.value)} onChange={(e) => updateField('bark_url', e.target.value)}
placeholder={t('https://api.day.app/yourkey/{{title}}/{{content}}')} placeholder={t('https://api.day.app/yourkey/{{title}}/{{content}}')}
@@ -215,11 +221,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
{/* Gotify Settings */} {/* Gotify Settings */}
{settings.notify_type === 'gotify' && ( {settings.notify_type === 'gotify' && (
<> <>
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label> <Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
<Input <Input
id='gotifyUrl' id='gotifyUrl'
type='url' type='url'
className='h-9'
value={settings.gotify_url} value={settings.gotify_url}
onChange={(e) => updateField('gotify_url', e.target.value)} onChange={(e) => updateField('gotify_url', e.target.value)}
placeholder={t('https://gotify.example.com')} 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')} {t('Enter the full URL of your Gotify server')}
</p> </p>
</div> </div>
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='gotifyToken'>{t('Gotify Application Token')}</Label> <Label htmlFor='gotifyToken'>{t('Gotify Application Token')}</Label>
<PasswordInput <PasswordInput
id='gotifyToken' id='gotifyToken'
@@ -240,11 +247,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
{t('Token obtained from your Gotify application')} {t('Token obtained from your Gotify application')}
</p> </p>
</div> </div>
<div className='space-y-2'> <div className='space-y-1.5'>
<Label htmlFor='gotifyPriority'>{t('Message Priority')}</Label> <Label htmlFor='gotifyPriority'>{t('Message Priority')}</Label>
<Input <Input
id='gotifyPriority' id='gotifyPriority'
type='number' type='number'
className='h-9'
min='0' min='0'
max='10' max='10'
value={settings.gotify_priority} value={settings.gotify_priority}
@@ -259,8 +267,8 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)} )}
</p> </p>
</div> </div>
<div className='bg-muted/50 rounded-lg border p-4'> <div className='bg-muted/50 rounded-lg border p-3 sm:p-4'>
<h5 className='mb-2 text-sm font-medium'> <h5 className='mb-1.5 text-sm font-medium sm:mb-2'>
{t('Setup Instructions')} {t('Setup Instructions')}
</h5> </h5>
<ol className='text-muted-foreground space-y-1 text-xs'> <ol className='text-muted-foreground space-y-1 text-xs'>
@@ -287,7 +295,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<div className='border-t' /> <div className='border-t' />
{/* Preferences Section */} {/* Preferences Section */}
<div className='space-y-4'> <div className='space-y-3'>
<div> <div>
<h4 className='text-sm font-medium'>{t('Preferences')}</h4> <h4 className='text-sm font-medium'>{t('Preferences')}</h4>
<p className='text-muted-foreground mt-1 text-xs'> <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) */} {/* Receive Upstream Model Update Notifications (admin only) */}
{isAdmin && ( {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'> <div className='space-y-0.5'>
<Label htmlFor='upstreamModelUpdateNotify'> <Label htmlFor='upstreamModelUpdateNotify'>
{t('Receive Upstream Model Update Notifications')} {t('Receive Upstream Model Update Notifications')}
</Label> </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( {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.' '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 */} {/* 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'> <div className='space-y-0.5'>
<Label htmlFor='acceptUnsetPrice'> <Label htmlFor='acceptUnsetPrice'>
{t('Accept Unpriced Models')} {t('Accept Unpriced Models')}
</Label> </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')} {t('Allow using models without price configuration')}
</p> </p>
</div> </div>
@@ -340,10 +348,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</div> </div>
{/* Record IP Log */} {/* 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'> <div className='space-y-0.5'>
<Label htmlFor='recordIp'>{t('Record IP Address')}</Label> <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')} {t('Log IP address for usage and error logs')}
</p> </p>
</div> </div>
@@ -33,12 +33,12 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
if (pageLoading || loading) { if (pageLoading || loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader> <CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' /> <Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' /> <Skeleton className='mt-2 h-4 w-64' />
</CardHeader> </CardHeader>
<CardContent> <CardContent className='p-3 sm:p-5'>
<Skeleton className='h-20 w-full' /> <Skeleton className='h-20 w-full' />
</CardContent> </CardContent>
</Card> </Card>
@@ -47,17 +47,17 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
return ( return (
<> <>
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader> <CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-xl tracking-tight'> <CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Two-Factor Authentication')} {t('Two-Factor Authentication')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className='text-xs sm:text-sm'>
{t('Add an extra layer of security to your account')} {t('Add an extra layer of security to your account')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className='p-3 sm:p-5'>
<div className='space-y-6'> <div className='space-y-6'>
{/* Status Section */} {/* Status Section */}
<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 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
+10 -5
View File
@@ -6,6 +6,7 @@ import {
CardStaggerItem, CardStaggerItem,
} from '@/components/page-transition' } from '@/components/page-transition'
import { CheckinCalendarCard } from './components/checkin-calendar-card' import { CheckinCalendarCard } from './components/checkin-calendar-card'
import { LanguagePreferencesCard } from './components/language-preferences-card'
import { PasskeyCard } from './components/passkey-card' import { PasskeyCard } from './components/passkey-card'
import { ProfileHeader } from './components/profile-header' import { ProfileHeader } from './components/profile-header'
import { ProfileSecurityCard } from './components/profile-security-card' import { ProfileSecurityCard } from './components/profile-security-card'
@@ -30,24 +31,28 @@ export function Profile() {
<> <>
<AppHeader /> <AppHeader />
<Main> <Main>
<div className='min-h-0 flex-1 overflow-auto px-4 py-4 sm:py-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-5 sm:gap-6'> <CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-6'>
<CardStaggerItem> <CardStaggerItem>
<ProfileHeader profile={profile} loading={loading} /> <ProfileHeader profile={profile} loading={loading} />
</CardStaggerItem> </CardStaggerItem>
<CardStaggerItem> <CardStaggerItem>
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'> <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-5 sm:space-y-6'> <div className='space-y-4 sm:space-y-6'>
<ProfileSettingsCard <ProfileSettingsCard
profile={profile} profile={profile}
loading={loading} loading={loading}
onProfileUpdate={refreshProfile} onProfileUpdate={refreshProfile}
/> />
<LanguagePreferencesCard
profile={profile}
onProfileUpdate={refreshProfile}
/>
<ProfileSecurityCard profile={profile} loading={loading} /> <ProfileSecurityCard profile={profile} loading={loading} />
</div> </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 && ( {checkinEnabled && (
<CheckinCalendarCard <CheckinCalendarCard
checkinEnabled={checkinEnabled} checkinEnabled={checkinEnabled}
+2
View File
@@ -98,6 +98,8 @@ export interface UserSettings {
record_ip_log?: boolean record_ip_log?: boolean
/** Receive upstream model update notifications (admin only) */ /** Receive upstream model update notifications (admin only) */
upstream_model_update_notify_enabled?: boolean upstream_model_update_notify_enabled?: boolean
/** Preferred interface/API response language */
language?: string
} }
/** /**
@@ -133,8 +133,8 @@ export function RedemptionsMutateDrawer({
} }
}} }}
> >
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'> <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='text-start'> <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle> <SheetTitle>
{isUpdate {isUpdate
? t('Update Redemption Code') ? t('Update Redemption Code')
@@ -153,7 +153,7 @@ export function RedemptionsMutateDrawer({
<form <form
id='redemption-form' id='redemption-form'
onSubmit={form.handleSubmit(onSubmit)} 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 <FormField
control={form.control} control={form.control}
@@ -215,7 +215,7 @@ export function RedemptionsMutateDrawer({
placeholder={t('Never expires')} placeholder={t('Never expires')}
/> />
</FormControl> </FormControl>
<div className='flex gap-2'> <div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -287,7 +287,7 @@ export function RedemptionsMutateDrawer({
)} )}
</form> </form>
</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> <SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button> <Button variant='outline'>{t('Close')}</Button>
</SheetClose> </SheetClose>
@@ -72,7 +72,7 @@ export function RedemptionsTable() {
} = useTableUrlState({ } = useTableUrlState({
search: route.useSearch(), search: route.useSearch(),
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 }, pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
globalFilter: { enabled: true, key: 'filter' }, globalFilter: { enabled: true, key: 'filter' },
columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }], columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }],
}) })
@@ -154,7 +154,7 @@ export function RedemptionsTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<DataTableToolbar <DataTableToolbar
table={table} table={table}
searchPlaceholder={t('Filter by name or ID...')} searchPlaceholder={t('Filter by name or ID...')}
@@ -165,7 +165,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
return ( return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <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> <DialogHeader>
<DialogTitle className='flex items-center gap-2'> <DialogTitle className='flex items-center gap-2'>
<Crown className='h-5 w-5' /> <Crown className='h-5 w-5' />
@@ -173,8 +173,8 @@ export function SubscriptionPurchaseDialog(props: Props) {
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-3 rounded-lg border p-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'> <div className='flex justify-between'>
<span className='text-muted-foreground text-sm'> <span className='text-muted-foreground text-sm'>
{t('Plan Name')} {t('Plan Name')}
@@ -239,7 +239,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
{t('Select payment method')} {t('Select payment method')}
</p> </p>
{(hasStripe || hasCreem) && ( {(hasStripe || hasCreem) && (
<div className='flex gap-2'> <div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && ( {hasStripe && (
<Button <Button
variant='outline' variant='outline'
@@ -263,7 +263,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
</div> </div>
)} )}
{hasEpay && ( {hasEpay && (
<div className='flex gap-2'> <div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select <Select
value={selectedEpayMethod} value={selectedEpayMethod}
onValueChange={setSelectedEpayMethod} onValueChange={setSelectedEpayMethod}
@@ -124,8 +124,8 @@ export function SubscriptionsMutateDrawer({
} }
}} }}
> >
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'> <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='text-start'> <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle> <SheetTitle>
{isEdit ? t('Update plan info') : t('Create new subscription plan')} {isEdit ? t('Update plan info') : t('Create new subscription plan')}
</SheetTitle> </SheetTitle>
@@ -141,7 +141,7 @@ export function SubscriptionsMutateDrawer({
<form <form
id='subscription-form' id='subscription-form'
onSubmit={form.handleSubmit(onSubmit)} 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 */} {/* Basic Info */}
<div className='space-y-4'> <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 <FormField
control={form.control} control={form.control}
name='price_amount' name='price_amount'
@@ -229,7 +229,7 @@ export function SubscriptionsMutateDrawer({
/> />
</div> </div>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField <FormField
control={form.control} control={form.control}
name='upgrade_group' name='upgrade_group'
@@ -288,7 +288,7 @@ export function SubscriptionsMutateDrawer({
/> />
</div> </div>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField <FormField
control={form.control} control={form.control}
name='sort_order' name='sort_order'
@@ -336,7 +336,7 @@ export function SubscriptionsMutateDrawer({
{t('Duration Settings')} {t('Duration Settings')}
</h3> </h3>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField <FormField
control={form.control} control={form.control}
name='duration_unit' name='duration_unit'
@@ -418,7 +418,7 @@ export function SubscriptionsMutateDrawer({
{t('Quota Reset')} {t('Quota Reset')}
</h3> </h3>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField <FormField
control={form.control} control={form.control}
name='quota_reset_period' name='quota_reset_period'
@@ -508,7 +508,7 @@ export function SubscriptionsMutateDrawer({
</div> </div>
</form> </form>
</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> <SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button> <Button variant='outline'>{t('Close')}</Button>
</SheetClose> </SheetClose>
@@ -62,7 +62,7 @@ export function SubscriptionsTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
{isMobile ? ( {isMobile ? (
<MobileCardList <MobileCardList
table={table} table={table}
@@ -769,7 +769,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</> </>
) )
}, },
meta: { label: t('Details'), mobileHidden: true }, meta: { label: t('Details') },
size: 180, size: 180,
maxSize: 200, maxSize: 200,
} }
@@ -141,9 +141,9 @@ export function CommonLogsFilterBar({
!!filters.requestId !!filters.requestId
return ( return (
<div className='space-y-3'> <div className='space-y-2 sm:space-y-3'>
{/* Primary filter row */} {/* 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 <CompactDateTimeRangePicker
start={filters.startTime} start={filters.startTime}
end={filters.endTime} end={filters.endTime}
@@ -214,7 +214,7 @@ export function CommonLogsFilterBar({
)} )}
> >
<div className='min-h-0 overflow-hidden'> <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 <Input
placeholder={t('Token Name')} placeholder={t('Token Name')}
type={sensitiveVisible ? 'text' : 'password'} 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 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'> <div className='flex min-w-0 flex-wrap items-center gap-2 sm:gap-3'>
{stats && <div className='min-w-0'>{stats}</div>} {stats && <div className='min-w-0'>{stats}</div>}
</div>
<div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
<button <button
type='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')} title={sensitiveVisible ? t('Hide') : t('Show')}
aria-label={sensitiveVisible ? t('Hide') : t('Show')} aria-label={sensitiveVisible ? t('Hide') : t('Show')}
onClick={() => setSensitiveVisible(!sensitiveVisible)} onClick={() => setSensitiveVisible(!sensitiveVisible)}
@@ -270,9 +273,6 @@ export function CommonLogsFilterBar({
<EyeOff className='size-3.5' /> <EyeOff className='size-3.5' />
)} )}
</button> </button>
</div>
<div className='flex shrink-0 items-center gap-2 self-end sm:self-auto'>
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
@@ -63,13 +63,13 @@ function DetailRow(props: {
muted?: boolean muted?: boolean
}) { }) {
return ( return (
<div className='flex items-start gap-3 text-sm'> <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 w-28 shrink-0 text-xs'> <span className='text-muted-foreground min-w-0 text-xs'>
{props.label} {props.label}
</span> </span>
<span <span
className={cn( 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.mono && 'font-mono',
props.muted && 'text-muted-foreground' props.muted && 'text-muted-foreground'
)} )}
@@ -88,7 +88,7 @@ function DetailSection(props: {
}) { }) {
const isDanger = props.variant === 'danger' const isDanger = props.variant === 'danger'
return ( return (
<div className='space-y-1.5'> <div className='min-w-0 space-y-1.5'>
<Label <Label
className={cn( className={cn(
'flex items-center gap-1.5 text-xs font-semibold', 'flex items-center gap-1.5 text-xs font-semibold',
@@ -100,7 +100,7 @@ function DetailSection(props: {
</Label> </Label>
<div <div
className={cn( 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 isDanger
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/20' ? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/20'
: 'bg-muted/30' : 'bg-muted/30'
@@ -462,11 +462,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent <DialogContent
className={cn( 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' 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'> <DialogTitle className='flex items-center gap-2 text-base'>
{t('Log Details')} {t('Log Details')}
<StatusBadge <StatusBadge
@@ -481,10 +482,10 @@ export function DetailsDialog(props: DetailsDialogProps) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className='max-h-[70vh] min-w-0 pr-4'> <ScrollArea className='max-h-[70vh] min-w-0 overflow-hidden pr-2 max-sm:max-h-[calc(100dvh-7rem)] sm:pr-4'>
<div className='min-w-0 space-y-3 py-1'> <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 */} {/* Overview section - key identifiers */}
<div className='space-y-1.5'> <div className='min-w-0 space-y-1'>
{props.log.request_id && ( {props.log.request_id && (
<DetailRow <DetailRow
label={t('Request ID')} label={t('Request ID')}
@@ -587,7 +588,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
{/* Request conversion (admin only, not for refund) */} {/* Request conversion (admin only, not for refund) */}
{showConversion && ( {showConversion && (
<DetailSection label={t('Request Conversion')}> <DetailSection label={t('Request Conversion')}>
<div className='relative'> <div className='relative min-w-0'>
<Button <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
@@ -602,7 +603,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
<Copy className='size-3' /> <Copy className='size-3' />
)} )}
</Button> </Button>
<div className='space-y-1 pr-6'> <div className='min-w-0 space-y-1 pr-6'>
{other?.request_path && ( {other?.request_path && (
<DetailRow <DetailRow
label={t('Path')} label={t('Path')}
@@ -610,12 +611,14 @@ export function DetailsDialog(props: DetailsDialogProps) {
mono 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 <Route
className='text-muted-foreground size-3' className='text-muted-foreground size-3'
aria-hidden='true' aria-hidden='true'
/> />
<span className='break-words'>{conversionLabel}</span> <span className='min-w-0 break-all sm:break-words'>
{conversionLabel}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -825,7 +828,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
{/* Tiered pricing breakdown (when billing_mode is tiered_expr) */} {/* Tiered pricing breakdown (when billing_mode is tiered_expr) */}
{isTieredBilling && other?.expr_b64 && ( {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 <DynamicPricingBreakdown
billingExpr={decodeBillingExprB64(other.expr_b64)} billingExpr={decodeBillingExprB64(other.expr_b64)}
matchedTierLabel={other.matched_tier} matchedTierLabel={other.matched_tier}
@@ -964,7 +967,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
return ( return (
<div <div
key={idx} 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 <StatusBadge
variant='neutral' variant='neutral'
@@ -972,7 +975,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
className='shrink-0 font-medium' className='shrink-0 font-medium'
copyable={false} 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} {parsed.content}
</span> </span>
</div> </div>
@@ -985,7 +988,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
{details && ( {details && (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>{t('Content')}</Label> <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 <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
@@ -1000,7 +1003,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
<Copy className='size-3' /> <Copy className='size-3' />
)} )}
</Button> </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} {details}
</p> </p>
</div> </div>
@@ -271,7 +271,7 @@ export function UsageLogsFilterDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <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> <DialogHeader>
<DialogTitle> <DialogTitle>
{t('Filter')} {t(getLogCategoryLabel(logCategory))} {t('Logs')} {t('Filter')} {t(getLogCategoryLabel(logCategory))} {t('Logs')}
@@ -281,15 +281,15 @@ export function UsageLogsFilterDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'> <ScrollArea className='min-h-0 flex-1 pr-3 sm:max-h-[60vh] sm:pr-4'>
<div className='grid gap-4 py-4'> <div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
{/* Quick time range selection */} {/* Quick time range selection */}
<div className='grid gap-2'> <div className='grid gap-2'>
<Label className='flex items-center gap-2'> <Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' /> <Calendar className='h-4 w-4' />
{t('Quick Range')} {t('Quick Range')}
</Label> </Label>
<div className='flex gap-2'> <div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => ( {TIME_RANGE_PRESETS.map((range) => (
<Button <Button
key={range.days} key={range.days}
@@ -314,7 +314,7 @@ export function UsageLogsFilterDialog({
<SectionDivider label={t('Custom Time Range')} /> <SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */} {/* Custom time range */}
<div className='grid gap-4'> <div className='grid gap-3 sm:gap-4'>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='start_time'>{t('Start Time')}</Label> <Label htmlFor='start_time'>{t('Start Time')}</Label>
<DateTimePicker <DateTimePicker
@@ -355,7 +355,7 @@ export function UsageLogsFilterDialog({
</div> </div>
</ScrollArea> </ScrollArea>
<DialogFooter> <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button onClick={handleReset} variant='outline' type='button'> <Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' /> <RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')} {t('Reset')}
@@ -138,8 +138,8 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) {
) )
return ( return (
<div className='space-y-3'> <div className='space-y-2 sm: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='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 <CompactDateTimeRangePicker
start={filters.startTime} start={filters.startTime}
end={filters.endTime} end={filters.endTime}
@@ -166,7 +166,7 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) {
className='h-9' 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 <Button
variant='outline' variant='outline'
size='sm' size='sm'
@@ -67,7 +67,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
} = useTableUrlState({ } = useTableUrlState({
search: route.useSearch(), search: route.useSearch(),
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 100 }, pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
globalFilter: { enabled: false }, globalFilter: { enabled: false },
columnFilters: [ columnFilters: [
{ columnId: 'created_at', searchKey: 'type', type: 'array' as const }, { columnId: 'created_at', searchKey: 'type', type: 'array' as const },
@@ -185,16 +185,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
{logCategory === 'common' ? ( {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 <CommonLogsFilterBar
stats={<CommonLogsStats />} stats={<CommonLogsStats />}
viewOptions={<DataTableViewOptions table={table} />} viewOptions={<DataTableViewOptions table={table} />}
/> />
</div> </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 <TaskLogsFilterBar
logCategory={logCategory} logCategory={logCategory}
viewOptions={<DataTableViewOptions table={table} />} viewOptions={<DataTableViewOptions table={table} />}
@@ -152,8 +152,8 @@ export function UsersMutateDrawer({
} }
}} }}
> >
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'> <SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='text-start'> <SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle> <SheetTitle>
{isUpdate ? t('Update') : t('Create')} {t('User')} {isUpdate ? t('Update') : t('Create')} {t('User')}
</SheetTitle> </SheetTitle>
@@ -167,7 +167,7 @@ export function UsersMutateDrawer({
<form <form
id='user-form' id='user-form'
onSubmit={form.handleSubmit(onSubmit)} 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 */} {/* Basic Information */}
<div className='space-y-4'> <div className='space-y-4'>
@@ -396,7 +396,7 @@ export function UsersMutateDrawer({
)} )}
</form> </form>
</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> <SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button> <Button variant='outline'>{t('Close')}</Button>
</SheetClose> </SheetClose>
+2 -2
View File
@@ -74,7 +74,7 @@ export function UsersTable() {
} = useTableUrlState({ } = useTableUrlState({
search: route.useSearch(), search: route.useSearch(),
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 }, pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
globalFilter: { enabled: true, key: 'filter' }, globalFilter: { enabled: true, key: 'filter' },
columnFilters: [ columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' }, { columnId: 'status', searchKey: 'status', type: 'array' },
@@ -168,7 +168,7 @@ export function UsersTable() {
return ( return (
<> <>
<div className='space-y-4'> <div className='space-y-3 sm:space-y-4'>
<DataTableToolbar <DataTableToolbar
table={table} table={table}
searchPlaceholder={t('Filter by username, name or email...')} searchPlaceholder={t('Filter by username, name or email...')}
@@ -2,15 +2,8 @@ import { Share2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { Card, CardContent } from '@/components/ui/card'
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import type { UserWalletData } from '../types' import type { UserWalletData } from '../types'
@@ -31,33 +24,14 @@ export function AffiliateRewardsCard({
const { t } = useTranslation() const { t } = useTranslation()
if (loading) { if (loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='bg-muted/20 py-0'>
<CardHeader className='border-b'> <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'>
<Skeleton className='h-6 w-32' /> <div>
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='h-5 w-32' />
</CardHeader> <Skeleton className='mt-2 h-4 w-48' />
<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>
))}
</div> </div>
<Skeleton className='h-14 rounded-lg' />
{/* Affiliate Link Skeleton */} <Skeleton className='h-10 rounded-lg' />
<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' />
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -66,89 +40,64 @@ export function AffiliateRewardsCard({
const hasRewards = (user?.aff_quota ?? 0) > 0 const hasRewards = (user?.aff_quota ?? 0) > 0
return ( return (
<Card className='overflow-hidden'> <Card className='bg-muted/20 py-0'>
<CardHeader className='border-b'> <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 items-center gap-3'> <div className='flex min-w-0 items-center gap-2.5'>
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'> <div className='bg-background flex size-8 shrink-0 items-center justify-center rounded-lg border'>
<Share2 className='h-4 w-4' /> <Share2 className='text-muted-foreground size-4' />
</div> </div>
<div className='min-w-0'> <div className='min-w-0'>
<CardTitle className='text-xl tracking-tight'> <h3 className='truncate text-sm font-semibold'>
{t('Referral Program')} {t('Referral Program')}
</CardTitle> </h3>
<CardDescription> <p className='text-muted-foreground line-clamp-1 text-xs'>
{t('Share your link and earn rewards')} {t(
</CardDescription> 'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.'
</div> )}
</div> </p>
</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>
</div> </div>
</div> </div>
{/* Transfer Button */} <div className='grid grid-cols-3 gap-1.5 text-center'>
{hasRewards && ( {[
<Button onClick={onTransfer} className='w-full' variant='default'> [t('Pending'), formatQuota(user?.aff_quota ?? 0)],
{t('Transfer to Balance')} [t('Total Earned'), formatQuota(user?.aff_history_quota ?? 0)],
</Button> [t('Invites'), String(user?.aff_count ?? 0)],
)} ].map(([label, value]) => (
<div key={label}>
{/* Affiliate Link */} <div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
<div className='space-y-3'> {label}
<Label className='text-muted-foreground text-xs tracking-wider uppercase'> </div>
{t('Your Referral Link')} <div className='mt-0.5 truncate text-sm font-semibold tabular-nums'>
</Label> {value}
<div className='flex gap-2'> </div>
<Input </div>
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> </div>
{/* Info */} <div className='flex items-center gap-2'>
<div className='bg-muted/30 space-y-2 rounded-lg p-4'> <Input
<p className='text-muted-foreground text-sm leading-relaxed'> value={affiliateLink}
{t( readOnly
'Earn rewards when your referrals add funds. Transfer accumulated rewards to your balance anytime.' className='border-muted bg-background/70 h-9 min-w-0 flex-1 font-mono text-xs'
)} />
</p> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -20,7 +20,7 @@ export function CreemProductsSection({
if (loading) { if (loading) {
return ( 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) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-24 rounded-lg' /> <Skeleton key={i} className='h-24 rounded-lg' />
))} ))}
@@ -33,14 +33,14 @@ export function CreemProductsSection({
} }
return ( 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) => ( {products.map((product) => (
<Card <Card
key={product.productId} key={product.productId}
className='hover:border-foreground/50 cursor-pointer transition-all hover:shadow-md' className='hover:border-foreground/50 cursor-pointer transition-all hover:shadow-md'
onClick={() => onProductSelect(product)} 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='mb-2 text-lg font-medium'>{product.name}</div>
<div className='text-muted-foreground mb-2 text-sm'> <div className='text-muted-foreground mb-2 text-sm'>
{t('Quota')}: {formatNumber(product.quota)} {t('Quota')}: {formatNumber(product.quota)}
@@ -83,7 +83,7 @@ export function BillingHistoryDialog({
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle>{t('Billing History')}</DialogTitle> <DialogTitle>{t('Billing History')}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -91,7 +91,7 @@ export function BillingHistoryDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4'> <div className='min-h-0 flex-1 space-y-3 sm:space-y-4'>
{/* Search and Filter Bar */} {/* Search and Filter Bar */}
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<div className='relative flex-1'> <div className='relative flex-1'>
@@ -100,14 +100,14 @@ export function BillingHistoryDialog({
placeholder={t('Search by order number...')} placeholder={t('Search by order number...')}
value={keyword} value={keyword}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
className='pl-10' className='h-9 pl-10'
/> />
</div> </div>
<Select <Select
value={pageSize.toString()} value={pageSize.toString()}
onValueChange={(value) => handlePageSizeChange(parseInt(value))} onValueChange={(value) => handlePageSizeChange(parseInt(value))}
> >
<SelectTrigger className='w-32'> <SelectTrigger className='h-9 w-[92px] sm:w-32'>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -120,11 +120,11 @@ export function BillingHistoryDialog({
</div> </div>
{/* Records List */} {/* Records List */}
<ScrollArea className='h-[500px] pr-4'> <ScrollArea className='h-[calc(100dvh-15rem)] pr-3 sm:h-[500px] sm:pr-4'>
{loading ? ( {loading ? (
<div className='space-y-3'> <div className='space-y-3'>
{Array.from({ length: 5 }).map((_, i) => ( {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 items-start justify-between'>
<div className='flex-1 space-y-2'> <div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-48' /> <Skeleton className='h-4 w-48' />
@@ -132,7 +132,7 @@ export function BillingHistoryDialog({
</div> </div>
<Skeleton className='h-5 w-16' /> <Skeleton className='h-5 w-16' />
</div> </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' /> <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> </div>
) : records.length === 0 ? ( ) : 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'> <p className='text-sm font-medium'>
{t('No billing records found')} {t('No billing records found')}
</p> </p>
@@ -158,13 +158,13 @@ export function BillingHistoryDialog({
return ( return (
<div <div
key={record.id} 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 */} {/* 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-1 space-y-1'>
<div className='flex items-center gap-2'> <div className='flex min-w-0 items-center gap-2'>
<code className='text-foreground font-mono text-sm'> <code className='text-foreground truncate font-mono text-sm'>
{record.trade_no} {record.trade_no}
</code> </code>
<Button <Button
@@ -201,7 +201,7 @@ export function BillingHistoryDialog({
</div> </div>
{/* Details Grid */} {/* 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'> <div className='space-y-1'>
<Label className='text-muted-foreground text-xs'> <Label className='text-muted-foreground text-xs'>
Payment Method Payment Method
@@ -34,7 +34,7 @@ export function CreemConfirmDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle> <DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -42,7 +42,7 @@ export function CreemConfirmDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </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'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Product')}</span> <span className='text-muted-foreground'>{t('Product')}</span>
<span className='font-medium'>{product.name}</span> <span className='font-medium'>{product.name}</span>
@@ -59,7 +59,7 @@ export function CreemConfirmDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button <Button
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
@@ -48,7 +48,7 @@ export function PaymentConfirmDialog({
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className='max-w-md'> <AlertDialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className='text-xl font-semibold'> <AlertDialogTitle className='text-xl font-semibold'>
{t('Confirm Payment')} {t('Confirm Payment')}
@@ -58,7 +58,7 @@ export function PaymentConfirmDialog({
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </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'> <div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'> <span className='text-muted-foreground text-sm'>
{t('Topup Amount')} {t('Topup Amount')}
@@ -121,7 +121,7 @@ export function PaymentConfirmDialog({
</div> </div>
</div> </div>
<AlertDialogFooter> <AlertDialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<AlertDialogCancel disabled={processing}> <AlertDialogCancel disabled={processing}>
{t('Cancel')} {t('Cancel')}
</AlertDialogCancel> </AlertDialogCancel>
@@ -49,7 +49,7 @@ export function TransferDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-md'> <DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader> <DialogHeader>
<DialogTitle className='text-xl font-semibold'> <DialogTitle className='text-xl font-semibold'>
{t('Transfer Rewards')} {t('Transfer Rewards')}
@@ -59,7 +59,7 @@ export function TransferDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </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'> <div className='space-y-2'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Available Rewards')} {t('Available Rewards')}
@@ -92,7 +92,7 @@ export function TransferDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button <Button
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
@@ -8,13 +8,12 @@ import { Button } from '@/components/ui/button'
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -125,13 +124,13 @@ export function RechargeFormCard({
if (loading) { if (loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b'> <CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' /> <Skeleton className='mt-2 h-4 w-48' />
</CardHeader> </CardHeader>
<CardContent className='space-y-6 pt-6'> <CardContent className='space-y-4 p-3 sm:space-y-6 sm:p-5'>
<div className='space-y-6'> <div className='space-y-4 sm:space-y-6'>
{/* Preset Amounts Skeleton */} {/* Preset Amounts Skeleton */}
<div className='space-y-3'> <div className='space-y-3'>
<Skeleton className='h-3 w-16' /> <Skeleton className='h-3 w-16' />
@@ -173,23 +172,12 @@ export function RechargeFormCard({
} }
return ( return (
<Card className='overflow-hidden'> <TitledCard
<CardHeader className='border-b'> title={t('Add Funds')}
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'> description={t('Choose an amount and payment method')}
<div className='flex min-w-0 items-center gap-3'> icon={<WalletCards className='h-4 w-4' />}
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'> action={
<WalletCards className='h-4 w-4' /> onOpenBilling ? (
</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 && (
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
@@ -199,21 +187,21 @@ export function RechargeFormCard({
<Receipt className='h-4 w-4' /> <Receipt className='h-4 w-4' />
{t('Order History')} {t('Order History')}
</Button> </Button>
)} ) : null
</div> }
</CardHeader> contentClassName='space-y-4 sm:space-y-6'
<CardContent className='space-y-6 pt-6'> >
{/* Online Topup Section */} {/* Online Topup Section */}
{hasAnyTopup ? ( {hasAnyTopup ? (
<div className='space-y-6'> <div className='space-y-4 sm:space-y-6'>
{hasConfigurableTopup && ( {hasConfigurableTopup && (
<> <>
{presetAmounts.length > 0 && ( {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'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Amount')} {t('Amount')}
</Label> </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) => { {presetAmounts.map((preset, index) => {
const discount = const discount =
preset.discount || preset.discount ||
@@ -235,7 +223,7 @@ export function RechargeFormCard({
key={index} key={index}
variant='outline' variant='outline'
className={cn( 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 selectedPreset === preset.value
? 'border-foreground bg-foreground/5' ? 'border-foreground bg-foreground/5'
: 'border-muted' : 'border-muted'
@@ -243,7 +231,7 @@ export function RechargeFormCard({
onClick={() => onSelectPreset(preset)} onClick={() => onSelectPreset(preset)}
> >
<div className='flex w-full items-center justify-between'> <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)} {formatNumber(displayValue)}
</div> </div>
{hasDiscount && ( {hasDiscount && (
@@ -252,7 +240,7 @@ export function RechargeFormCard({
</div> </div>
)} )}
</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)} Pay {formatCurrency(actualPrice)}
{hasDiscount && savedAmount > 0 && ( {hasDiscount && savedAmount > 0 && (
<span className='text-green-600'> <span className='text-green-600'>
@@ -268,14 +256,14 @@ export function RechargeFormCard({
</div> </div>
)} )}
<div className='space-y-3'> <div className='space-y-2.5 sm:space-y-3'>
<Label <Label
htmlFor='topup-amount' htmlFor='topup-amount'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase' className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
> >
{t('Custom Amount')} {t('Custom Amount')}
</Label> </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 <Input
id='topup-amount' id='topup-amount'
type='number' type='number'
@@ -283,10 +271,10 @@ export function RechargeFormCard({
onChange={(e) => handleAmountChange(e.target.value)} onChange={(e) => handleAmountChange(e.target.value)}
min={minTopup} min={minTopup}
placeholder={`Minimum ${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'> <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 text-xs'> <span className='text-muted-foreground truncate text-xs'>
{t('Amount to pay:')} {t('Amount to pay:')}
</span> </span>
{calculating ? ( {calculating ? (
@@ -300,12 +288,12 @@ export function RechargeFormCard({
</div> </div>
</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'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Payment Method')} {t('Payment Method')}
</Label> </Label>
{hasStandardPaymentMethods ? ( {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) => { {topupInfo?.pay_methods?.map((method) => {
const minTopup = method.min_topup || 0 const minTopup = method.min_topup || 0
const disabled = minTopup > topupAmount const disabled = minTopup > topupAmount
@@ -316,7 +304,7 @@ export function RechargeFormCard({
variant='outline' variant='outline'
onClick={() => onPaymentMethodSelect(method)} onClick={() => onPaymentMethodSelect(method)}
disabled={disabled || !!paymentLoading} 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 ? ( {paymentLoading === method.type ? (
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className='h-4 w-4 animate-spin' />
@@ -328,7 +316,7 @@ export function RechargeFormCard({
method.name method.name
) )
)} )}
{method.name} <span className='truncate'>{method.name}</span>
</Button> </Button>
) )
@@ -362,11 +350,11 @@ export function RechargeFormCard({
{enableWaffoTopup && {enableWaffoTopup &&
hasWaffoPaymentMethods && hasWaffoPaymentMethods &&
onWaffoMethodSelect && ( 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'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Waffo Payment')} {t('Waffo Payment')}
</Label> </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) => { {waffoPayMethods?.map((method, index) => {
const loadingKey = `waffo-${index}` const loadingKey = `waffo-${index}`
const waffoMin = waffoMinTopup || 0 const waffoMin = waffoMinTopup || 0
@@ -378,7 +366,7 @@ export function RechargeFormCard({
variant='outline' variant='outline'
onClick={() => onWaffoMethodSelect(method, index)} onClick={() => onWaffoMethodSelect(method, index)}
disabled={belowMin || !!paymentLoading} 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 ? ( {paymentLoading === loadingKey ? (
<Loader2 className='h-4 w-4 animate-spin' /> <Loader2 className='h-4 w-4 animate-spin' />
@@ -391,7 +379,7 @@ export function RechargeFormCard({
) : ( ) : (
getPaymentIcon('waffo') getPaymentIcon('waffo')
)} )}
{method.name} <span className='truncate'>{method.name}</span>
</Button> </Button>
) )
@@ -433,7 +421,7 @@ export function RechargeFormCard({
Array.isArray(creemProducts) && Array.isArray(creemProducts) &&
creemProducts.length > 0 && creemProducts.length > 0 &&
onCreemProductSelect && ( 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'> <Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Creem Payment')} {t('Creem Payment')}
</Label> </Label>
@@ -445,7 +433,7 @@ export function RechargeFormCard({
)} )}
{/* Redemption Code Section */} {/* 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'> <div className='flex items-center gap-2'>
<Gift className='text-muted-foreground h-4 w-4' /> <Gift className='text-muted-foreground h-4 w-4' />
<Label <Label
@@ -455,19 +443,19 @@ export function RechargeFormCard({
{t('Have a Code?')} {t('Have a Code?')}
</Label> </Label>
</div> </div>
<div className='flex flex-col gap-2 sm:flex-row'> <div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Input <Input
id='redemption-code' id='redemption-code'
value={redemptionCode} value={redemptionCode}
onChange={(e) => onRedemptionCodeChange(e.target.value)} onChange={(e) => onRedemptionCodeChange(e.target.value)}
placeholder={t('Enter your redemption code')} placeholder={t('Enter your redemption code')}
className='flex-1' className='h-9 min-w-0'
/> />
<Button <Button
onClick={onRedeem} onClick={onRedeem}
disabled={redeeming} disabled={redeeming}
variant='outline' variant='outline'
className='sm:w-auto' className='h-9 px-4'
> >
{redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} {redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Redeem')} {t('Redeem')}
@@ -488,7 +476,6 @@ export function RechargeFormCard({
</p> </p>
)} )}
</div> </div>
</CardContent> </TitledCard>
</Card>
) )
} }
@@ -9,9 +9,7 @@ import { Button } from '@/components/ui/button'
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { import {
@@ -23,6 +21,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -48,6 +47,7 @@ import type { PaymentMethod, TopupInfo } from '../types'
interface SubscriptionPlansCardProps { interface SubscriptionPlansCardProps {
topupInfo: TopupInfo | null topupInfo: TopupInfo | null
onAvailabilityChange?: (available: boolean) => void
} }
function getEpayMethods(payMethods: PaymentMethod[] = []): PaymentMethod[] { 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 { t } = useTranslation()
const { status } = useStatus() const { status } = useStatus()
@@ -76,11 +79,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
const [selectedPlan, setSelectedPlan] = useState<PlanRecord | null>(null) const [selectedPlan, setSelectedPlan] = useState<PlanRecord | null>(null)
const enableStripe = !!status?.enable_stripe_topup 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 enableOnlineTopUp = !!status?.enable_online_topup
const epayMethods = useMemo( const epayMethods = useMemo(
() => getEpayMethods(props.topupInfo?.pay_methods), () => getEpayMethods(topupInfo?.pay_methods),
[props.topupInfo?.pay_methods] [topupInfo?.pay_methods]
) )
const fetchPlans = useCallback(async () => { const fetchPlans = useCallback(async () => {
@@ -148,6 +151,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
const hasActive = activeSubscriptions.length > 0 const hasActive = activeSubscriptions.length > 0
const hasAny = allSubscriptions.length > 0 const hasAny = allSubscriptions.length > 0
const isAvailable = loading || plans.length > 0 || hasAny
const disablePref = !hasActive const disablePref = !hasActive
const isSubPref = const isSubPref =
billingPreference === 'subscription_first' || billingPreference === 'subscription_first' ||
@@ -165,6 +169,10 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
return map return map
}, [allSubscriptions]) }, [allSubscriptions])
useEffect(() => {
onAvailabilityChange?.(isAvailable)
}, [isAvailable, onAvailabilityChange])
const planTitleMap = useMemo(() => { const planTitleMap = useMemo(() => {
const map = new Map<number, string>() const map = new Map<number, string>()
for (const p of plans) { for (const p of plans) {
@@ -191,11 +199,11 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
if (loading) { if (loading) {
return ( return (
<Card className='overflow-hidden'> <Card className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b'> <CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' /> <Skeleton className='h-6 w-32' />
</CardHeader> </CardHeader>
<CardContent className='space-y-4 pt-6'> <CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-20 w-full' /> <Skeleton className='h-20 w-full' />
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'> <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
@@ -213,27 +221,16 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
return ( return (
<> <>
<Card className='overflow-hidden'> <TitledCard
<CardHeader className='border-b'> title={t('Subscription Plans')}
<div className='flex items-center gap-3'> description={t('Purchase a plan to enjoy model benefits')}
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'> icon={<Crown className='h-4 w-4' />}
<Crown className='h-4 w-4' /> contentClassName='space-y-4 sm:space-y-5'
</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'>
{/* My subscriptions & billing preference */} {/* My subscriptions & billing preference */}
<div className='rounded-xl border p-4'> <div className='rounded-xl border p-3 sm:p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'> <div className='flex flex-wrap items-center justify-between gap-2.5 sm:gap-3'>
<div className='flex items-center gap-2'> <div className='flex min-w-0 flex-wrap items-center gap-2'>
<span className='text-sm font-medium'> <span className='text-sm font-medium'>
{t('My Subscriptions')} {t('My Subscriptions')}
</span> </span>
@@ -265,12 +262,12 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
)} )}
</span> </span>
</div> </div>
<div className='flex items-center gap-2'> <div className='flex w-full items-center gap-2 sm:w-auto'>
<Select <Select
value={displayPref} value={displayPref}
onValueChange={handlePreferenceChange} 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -452,7 +449,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
{/* Available plans grid */} {/* Available plans grid */}
{plans.length > 0 ? ( {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) => { {plans.map((p, index) => {
const plan = p?.plan const plan = p?.plan
if (!plan) return null if (!plan) return null
@@ -485,7 +482,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
isPopular && 'border-primary/70 shadow-sm' 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='mb-2 flex items-start justify-between gap-3'>
<div className='min-w-0'> <div className='min-w-0'>
<h4 className='truncate font-semibold'> <h4 className='truncate font-semibold'>
@@ -568,8 +565,7 @@ export function SubscriptionPlansCard(props: SubscriptionPlansCardProps) {
{t('No plans available')} {t('No plans available')}
</p> </p>
)} )}
</CardContent> </TitledCard>
</Card>
<SubscriptionPurchaseDialog <SubscriptionPurchaseDialog
open={purchaseOpen} open={purchaseOpen}
@@ -14,9 +14,9 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
if (props.loading) { if (props.loading) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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) => ( {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='h-3.5 w-20' />
<Skeleton className='mt-2 h-7 w-28' /> <Skeleton className='mt-2 h-7 w-28' />
<Skeleton className='mt-1.5 h-3.5 w-24' /> <Skeleton className='mt-1.5 h-3.5 w-24' />
@@ -50,9 +50,9 @@ export function WalletStatsCard(props: WalletStatsCardProps) {
return ( return (
<div className='overflow-hidden rounded-lg border'> <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) => ( {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'> <div className='flex items-center gap-2'>
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' /> <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'> <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> </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} {item.value}
</div> </div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'> <div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
+28 -13
View File
@@ -54,6 +54,7 @@ export function Wallet(props: WalletProps) {
const [creemDialogOpen, setCreemDialogOpen] = useState(false) const [creemDialogOpen, setCreemDialogOpen] = useState(false)
const [selectedCreemProduct, setSelectedCreemProduct] = const [selectedCreemProduct, setSelectedCreemProduct] =
useState<CreemProduct | null>(null) useState<CreemProduct | null>(null)
const [showSubscriptionPanel, setShowSubscriptionPanel] = useState(true)
const { status } = useStatus() const { status } = useStatus()
const { currency } = useSystemConfig() const { currency } = useSystemConfig()
@@ -231,6 +232,13 @@ export function Wallet(props: WalletProps) {
return topupInfo?.discount?.[topupAmount] || DEFAULT_DISCOUNT_RATE return topupInfo?.discount?.[topupAmount] || DEFAULT_DISCOUNT_RATE
}, [topupInfo, topupAmount]) }, [topupInfo, topupAmount])
const handleSubscriptionAvailabilityChange = useCallback(
(available: boolean) => {
setShowSubscriptionPanel(available)
},
[]
)
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout>
@@ -239,13 +247,17 @@ export function Wallet(props: WalletProps) {
{t('Manage your balance and payment methods')} {t('Manage your balance and payment methods')}
</SectionPageLayout.Description> </SectionPageLayout.Description>
<SectionPageLayout.Content> <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} /> <WalletStatsCard user={user} loading={userLoading} />
<SubscriptionPlansCard topupInfo={topupInfo} /> <div
className={
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.4fr)] xl:items-start'> showSubscriptionPanel
<div className='min-w-0'> ? '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 <RechargeFormCard
topupInfo={topupInfo} topupInfo={topupInfo}
presetAmounts={presetAmounts} presetAmounts={presetAmounts}
@@ -279,15 +291,18 @@ export function Wallet(props: WalletProps) {
/> />
</div> </div>
<div className='xl:sticky xl:top-6'> <SubscriptionPlansCard
<AffiliateRewardsCard topupInfo={topupInfo}
user={user} onAvailabilityChange={handleSubscriptionAvailabilityChange}
affiliateLink={affiliateLink} />
onTransfer={() => setTransferDialogOpen(true)}
loading={affiliateLoading}
/>
</div>
</div> </div>
<AffiliateRewardsCard
user={user}
affiliateLink={affiliateLink}
onTransfer={() => setTransferDialogOpen(true)}
loading={affiliateLoading}
/>
</div> </div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
+6
View File
@@ -1817,6 +1817,7 @@
"Inter-group ratio overrides": "Inter-group ratio overrides", "Inter-group ratio overrides": "Inter-group ratio overrides",
"Internal Notes": "Internal Notes", "Internal Notes": "Internal Notes",
"Internal notes (not shown to users)": "Internal notes (not shown to users)", "Internal notes (not shown to users)": "Internal notes (not shown to users)",
"Interface Language": "Interface Language",
"Internal Server Error!": "Internal Server Error!", "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 the administrator.": "Invalid chat link. Please contact the administrator.",
"Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.", "Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.",
@@ -1893,6 +1894,9 @@
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "Knowledge Base ID *", "Knowledge Base ID *": "Knowledge Base ID *",
"Landing page with system overview.": "Landing page with system overview.", "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 check time": "Last check time",
"Last detected addable models": "Last detected addable models", "Last detected addable models": "Last detected addable models",
"Last Login": "Last Login", "Last Login": "Last Login",
@@ -3108,6 +3112,7 @@
"Select items...": "Select items...", "Select items...": "Select items...",
"Select key format": "Select key format", "Select key format": "Select key format",
"Select Language": "Select Language", "Select Language": "Select Language",
"Select language": "Select language",
"Select layout style": "Select layout style", "Select layout style": "Select layout style",
"Select locations": "Select locations", "Select locations": "Select locations",
"Select Model": "Select Model", "Select Model": "Select Model",
@@ -3171,6 +3176,7 @@
"Set quota amount and limits": "Set quota amount and limits", "Set quota amount and limits": "Set quota amount and limits",
"Set Request Header": "Set Request Header", "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 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": "Set Tag",
"Set tag for selected channels": "Set tag for selected channels", "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)", "Set the user's role (cannot be Root)": "Set the user's role (cannot be Root)",
+6
View File
@@ -1817,6 +1817,7 @@
"Inter-group ratio overrides": "Dérogations de ratio inter-groupes", "Inter-group ratio overrides": "Dérogations de ratio inter-groupes",
"Internal Notes": "Notes internes", "Internal Notes": "Notes internes",
"Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)", "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 !", "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 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.", "Invalid chat link. Please contact your administrator.": "Lien de chat invalide. Veuillez contacter votre administrateur.",
@@ -1893,6 +1894,9 @@
"Kling": "Kling", "Kling": "Kling",
"Knowledge Base ID *": "ID de la base de connaissances *", "Knowledge Base ID *": "ID de la base de connaissances *",
"Landing page with system overview.": "Page d'accueil avec aperçu du système.", "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 check time": "Dernière vérification",
"Last detected addable models": "Derniers modèles ajoutables détectés", "Last detected addable models": "Derniers modèles ajoutables détectés",
"Last Login": "Dernière connexion", "Last Login": "Dernière connexion",
@@ -3108,6 +3112,7 @@
"Select items...": "Sélectionner des éléments...", "Select items...": "Sélectionner des éléments...",
"Select key format": "Sélectionner le format de clé", "Select key format": "Sélectionner le format de clé",
"Select Language": "Sélectionner la langue", "Select Language": "Sélectionner la langue",
"Select language": "Sélectionner une langue",
"Select layout style": "Sélectionner le style de mise en page", "Select layout style": "Sélectionner le style de mise en page",
"Select locations": "Sélectionner des emplacements", "Select locations": "Sélectionner des emplacements",
"Select Model": "Sélectionner le modèle", "Select Model": "Sélectionner le modèle",
@@ -3171,6 +3176,7 @@
"Set quota amount and limits": "Définir le quota et les limites", "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 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 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": "Définir un tag",
"Set tag for selected channels": "Définir un tag pour les canaux sélectionnés", "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)", "Set the user's role (cannot be Root)": "Définir le rôle de l'utilisateur (ne peut pas être Root)",
+6
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": "インターフェース言語",
"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", "Kling": "Kling",
"Knowledge Base ID *": "ナレッジベースID *", "Knowledge Base ID *": "ナレッジベースID *",
"Landing page with system overview.": "システム概要付きランディングページ。", "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 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 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 Tag": "タグを設定", "Set Tag": "タグを設定",
"Set tag for selected channels": "選択したチャネルにタグを設定", "Set tag for selected channels": "選択したチャネルにタグを設定",
"Set the user's role (cannot be Root)": "ユーザーのロールを設定します(Rootにはできません)", "Set the user's role (cannot be Root)": "ユーザーのロールを設定します(Rootにはできません)",
+6
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": "Язык интерфейса",
"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", "Kling": "Kling",
"Knowledge Base ID *": "ID базы знаний *", "Knowledge Base ID *": "ID базы знаний *",
"Landing page with system overview.": "Главная страница с обзором системы.", "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 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 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 Tag": "Установить тег", "Set Tag": "Установить тег",
"Set tag for selected channels": "Установить тег для выбранных каналов", "Set tag for selected channels": "Установить тег для выбранных каналов",
"Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)", "Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)",
+6
View File
@@ -1817,6 +1817,7 @@
"Inter-group ratio overrides": "Tỷ lệ liên nhóm ghi đè", "Inter-group ratio overrides": "Tỷ lệ liên nhóm ghi đè",
"Internal Notes": "Ghi chú nội bộ", "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)", "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ộ!", "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 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.", "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", "Kling": "Kling",
"Knowledge Base ID *": "Mã số Cơ sở kiến thức *", "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.", "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 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 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", "Last Login": "Lần đăng nhập cuối",
@@ -3108,6 +3112,7 @@
"Select items...": "Chọn các mục...", "Select items...": "Chọn các mục...",
"Select key format": "Chọn định dạng khóa", "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 language": "Chọn ngôn ngữ",
"Select layout style": "Chọn kiểu bố cục", "Select layout style": "Chọn kiểu bố cục",
"Select locations": "Chọn vị trí", "Select locations": "Chọn vị trí",
"Select Model": "Chọn mẫu", "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 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 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 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": "Gán Thẻ",
"Set tag for selected channels": "Đặt thẻ cho các kênh đã chọn", "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)", "Set the user's role (cannot be Root)": "Đặt vai trò của người dùng (không được là Root)",
+6
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": "界面语言",
"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", "Kling": "Kling",
"Knowledge Base ID *": "知识库 ID *", "Knowledge Base ID *": "知识库 ID *",
"Landing page with system overview.": "带有系统概览的登陆页面。", "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 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 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": "设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做处理", "Set runtime request header: override entire value, or manipulate comma-separated tokens": "设置运行期请求头:可直接覆盖整条值,也可对逗号分隔的 token 做处理",
"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)": "设置用户角色(不能是 Root", "Set the user's role (cannot be Root)": "设置用户角色(不能是 Root",
+14
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) * Compute time range as Unix timestamps (seconds)
* @param days Default number of days if no dates provided * @param days Default number of days if no dates provided
+1 -1
View File
@@ -26,7 +26,7 @@ export interface AuthUser {
wechat_id?: string wechat_id?: string
telegram_id?: string telegram_id?: string
linux_do_id?: string linux_do_id?: string
setting?: Record<string, unknown> setting?: Record<string, unknown> | string
stripe_customer?: string stripe_customer?: string
sidebar_modules?: string sidebar_modules?: string
permissions?: UserPermissions permissions?: UserPermissions