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