feat(ui): improve mobile responsive layouts
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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...')}
|
||||||
|
|||||||
+5
-5
@@ -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')}
|
||||||
|
|||||||
+4
-4
@@ -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}`}
|
||||||
|
|||||||
+9
-9
@@ -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')}
|
||||||
|
|||||||
+4
-4
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -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
@@ -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
@@ -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
@@ -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...')}
|
||||||
|
|||||||
+7
-7
@@ -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')}
|
||||||
|
|||||||
+5
-5
@@ -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'>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -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 (
|
||||||
|
|||||||
+11
-11
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+5
-5
@@ -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...')}
|
||||||
|
|||||||
+5
-5
@@ -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}
|
||||||
|
|||||||
+9
-9
@@ -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}
|
||||||
|
|||||||
+1
-1
@@ -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'
|
||||||
|
|||||||
+22
-19
@@ -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>
|
||||||
|
|||||||
+6
-6
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
+13
-13
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -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)}
|
||||||
|
|||||||
+3
-3
@@ -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
@@ -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>
|
||||||
|
|||||||
Vendored
+6
@@ -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)",
|
||||||
|
|||||||
Vendored
+6
@@ -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)",
|
||||||
|
|||||||
Vendored
+6
@@ -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にはできません)",
|
||||||
|
|||||||
Vendored
+6
@@ -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)",
|
||||||
|
|||||||
Vendored
+6
@@ -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)",
|
||||||
|
|||||||
Vendored
+6
@@ -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)",
|
||||||
|
|||||||
Vendored
+14
@@ -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
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user