feat(ui): improve mobile responsive layouts

This commit is contained in:
CaIon
2026-04-30 19:53:02 +08:00
parent aa730395f1
commit d46df94f05
84 changed files with 1174 additions and 731 deletions
@@ -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})`
+4 -4
View File
@@ -191,9 +191,9 @@ export function Dashboard() {
{t(meta.descriptionKey)}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<div className='space-y-4'>
<div className='space-y-3 sm:space-y-4'>
{activeSection !== 'overview' && (
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
{showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
@@ -208,7 +208,7 @@ export function Dashboard() {
<div />
)}
{modelActions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2'>
<div className='flex shrink-0 flex-wrap items-center gap-1.5 sm:gap-2'>
{modelActions}
</div>
)}
@@ -217,7 +217,7 @@ export function Dashboard() {
{activeSection === 'overview' && (
<>
<SummaryCards />
<CardStaggerContainer className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<CardStaggerContainer className='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2'>
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TimeGranularity } from '@/lib/time'
import { getNormalizedDateRange } from '@/lib/time'
import { getRollingDateRange } from '@/lib/time'
import {
DASHBOARD_CHART_PREFERENCES_STORAGE_KEY,
DEFAULT_DASHBOARD_CHART_PREFERENCES,
@@ -128,7 +128,7 @@ export function getDefaultDays(granularity?: TimeGranularity): number {
export function buildDefaultDashboardFilters(
preferences: DashboardChartPreferences = getSavedChartPreferences()
): DashboardFilters {
const { start, end } = getNormalizedDateRange(preferences.defaultTimeRangeDays)
const { start, end } = getRollingDateRange(preferences.defaultTimeRangeDays)
return {
...EMPTY_DASHBOARD_FILTERS,
start_timestamp: start,