feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)

* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges

Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage.

Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx.

Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark).

Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi.

* 🎨 fix(web): align segmented controls with theme radius tokens

- Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart
  toolbars with radius-md so the active state follows --radius when users
  change Radius in Theme Settings.
- Use nested radii consistent with TabsList/TabsTrigger: outer
  rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px))
  so the track and active thumb stay concentric at small scales (e.g.
  0.3rem) instead of a squared “focus” block inside a rounded shell.
- Apply the same pattern to pricing SegmentedControl and the segmented
  groups in consumption-distribution-chart, model-charts, and user-charts.

Verified: bun run typecheck (web/default)

*  feat(pricing): enrich model details with uptime sparkline and API documentation

Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with
per-day tooltips, surface it in a status row under quick stats and in the
per-group performance table, and extend mock data so uptime series are stable
and optionally scoped by group.

Introduce an API tab with Shiki-highlighted code samples (cURL, Python,
TypeScript, JavaScript), endpoint-type switching, authentication guidance, a
supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer
vendor, tokenizer, license, and data-retention hints for a provider & data
privacy card on the Overview tab (capabilities/modalities stay with model
identity; rate limits stay with the API tab).

Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi.

* 🏆 feat(rankings): add comprehensive rankings dashboard

Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending.

Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales.

* fix(theme): correct theme preset selection state

- update Base UI Radio selectors to use data-checked/data-unchecked states.
- fix unchecked theme options still showing selected indicators.
- isolate the default theme preview tokens to prevent preset changes from leaking into it.

* fix(setup): correct usage mode radio state

- use Base UI data-checked/data-unchecked states for RadioGroup styling.
- hide radio indicators when options are unchecked to avoid setup page display issues.
- drive usage mode card and icon selection styles from Base UI state.

* fix(auth): submit sign-in and sign-up forms

* 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization

Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`)
and reinstall full component registry with `--overwrite`, including Hugeicons-backed
widgets and newly added registry components.

- Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies,
  preset UI from config drawer); rely on official semantic CSS tokens + light/dark only.
- Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and
  `@theme inline` mappings (plus skeleton token vars for existing shimmer usage).
- Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`;
  clarify scroll-lock override comment.

Application compatibility:
- Keep minimal shims where app code diverged from official APIs (popover collision props,
  combobox legacy `options` callers, Spinner prop typing).
- Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI
  semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.)

Tooling / docs / build:
- Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`.
- Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack.
- Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.)

Verified: `bun run typecheck` passes.

Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere;
not addressed in this change.

* 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters

- Refactor DataTableToolbar to a single wrapping flex row with a
  right-aligned action cluster (Reset / Search / View / Expand) for a
  cleaner Ant Design Pro–style filter bar; remove the dedicated stats row
  and the toolbar `stats` prop.
- Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive-
  data visibility toggle into the page header via CommonLogsHeaderActions
  and SectionPageLayout.Actions so the toolbar stays focused on filters.
- Slim CommonLogsFilterBar props (no stats / preActions eye control).
- Improve CompactDateTimeRangePicker: show minute-precision labels on the
  trigger (seconds omitted; aligns with datetime-local inputs); widen the
  trigger on sm+ breakpoints so the full range is visible without truncation;
  apply the same width in task logs filters.
- Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon.
- Earlier layout tweak: extra top padding on SectionPageLayout scroll
  content so control focus rings are not clipped by overflow.

* feat(web/default): Base UI migration and component foundation

Migrate from Radix UI to Base UI, rewrite core UI primitives,
update dependencies (recharts, date-fns, next-themes), add
shadcn agent skill documentation, and refresh AI element components.

This is the foundational work from the v2/localmain lineage that
was not covered by the individual feature commits above.

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
This commit is contained in:
Calcium-Ion
2026-05-06 12:39:36 +08:00
committed by GitHub
co-authored by t0ng7u QuentinHsu
parent dac55f0fde
commit 8b2b03d276
317 changed files with 19928 additions and 7065 deletions
+3 -1
View File
@@ -44,7 +44,9 @@ export async function updateUserSettings(
/**
* Update interface language preference
*/
export async function updateUserLanguage(language: string): Promise<ApiResponse> {
export async function updateUserLanguage(
language: string
): Promise<ApiResponse> {
const res = await api.put('/api/user/self', { language })
return res.data
}
@@ -226,7 +226,7 @@ export function CheckinCalendarCard({
}
return (
<TooltipProvider delayDuration={100}>
<TooltipProvider delay={100}>
<Dialog
open={turnstileModalVisible}
onOpenChange={(open) => {
@@ -425,7 +425,7 @@ export function CheckinCalendarCard({
if (isCheckedIn && dayObj.isCurrentMonth) {
return (
<Tooltip key={idx}>
<TooltipTrigger asChild>{dayButton}</TooltipTrigger>
<TooltipTrigger render={dayButton}></TooltipTrigger>
<TooltipContent>
<div className='text-xs'>
<div className='font-medium'>
@@ -54,7 +54,8 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
setCurrentLanguage(savedLanguage)
}, [savedLanguage])
const handleLanguageChange = async (language: string) => {
const handleLanguageChange = async (language: string | null) => {
if (!language) return
const nextLanguage = normalizeLanguage(language)
if (nextLanguage === currentLanguage) return
@@ -100,37 +101,37 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
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 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.'
)}
</div>
</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>
)
}
+47 -45
View File
@@ -204,43 +204,43 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
<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>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{t('Passkey Authentication')}</p>
<StatusBadge
label={enabled ? t('Enabled') : t('Disabled')}
variant={enabled ? 'success' : 'neutral'}
showDot
copyable={false}
/>
{status?.backup_eligible !== undefined && (
<div className='bg-muted rounded-md p-2'>
<KeyRound className='h-5 w-5' />
</div>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{t('Passkey Authentication')}</p>
<StatusBadge
label={
status.backup_eligible
? status.backup_state
? t('Backed up')
: t('Not backed up')
: t('No backup')
}
variant={
status.backup_eligible
? status.backup_state
? 'success'
: 'warning'
: 'neutral'
}
label={enabled ? t('Enabled') : t('Disabled')}
variant={enabled ? 'success' : 'neutral'}
showDot
copyable={false}
/>
)}
{status?.backup_eligible !== undefined && (
<StatusBadge
label={
status.backup_eligible
? status.backup_state
? t('Backed up')
: t('Not backed up')
: t('No backup')
}
variant={
status.backup_eligible
? status.backup_state
? 'success'
: 'warning'
: 'neutral'
}
showDot
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{t('Last used:')} {formattedLastUsed}
</p>
</div>
<p className='text-muted-foreground text-sm'>
{t('Last used:')} {formattedLastUsed}
</p>
</div>
</div>
{!enabled && (
@@ -260,19 +260,21 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
{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}
>
{removing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<AlertTriangle className='mr-2 h-4 w-4' />
)}
{t('Remove Passkey')}
</Button>
<AlertDialogTrigger
render={
<Button
variant='destructive'
className='flex-1'
disabled={removing}
/>
}
>
{removing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<AlertTriangle className='mr-2 h-4 w-4' />
)}
{t('Remove Passkey')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
@@ -1,11 +1,7 @@
import { Shield, Key, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useDialogs } from '@/hooks/use-dialog'
import {
Card,
CardContent,
CardHeader,
} from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import type { UserProfile } from '../types'
@@ -80,36 +76,36 @@ export function ProfileSecurityCard({
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 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 ${
<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 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'
: ''
}`}
>
<div
className={`rounded-md p-2 ${
item.variant === 'destructive'
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5'
: ''
? 'bg-destructive/10 text-destructive'
: 'bg-muted'
}`}
>
<div
className={`rounded-md p-2 ${
item.variant === 'destructive'
? 'bg-destructive/10 text-destructive'
: 'bg-muted'
}`}
>
<item.icon className='h-5 w-5' />
</div>
<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>
<item.icon className='h-5 w-5' />
</div>
<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>
</TitledCard>
{/* Dialogs */}
@@ -1,11 +1,7 @@
import { useState } from 'react'
import { Link2, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Card,
CardContent,
CardHeader,
} from '@/components/ui/card'
import { Card, CardContent, CardHeader } 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'
@@ -54,36 +50,36 @@ export function ProfileSettingsCard({
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'
>
<Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
<span className='sm:hidden'>{t('Bindings')}</span>
</TabsTrigger>
<TabsTrigger
value='settings'
className='h-auto gap-2 rounded-lg px-3 py-2'
>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>
{t('Settings & Preferences')}
</span>
<span className='sm:hidden'>{t('Settings')}</span>
</TabsTrigger>
</TabsList>
<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'
>
<Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
<span className='sm:hidden'>{t('Bindings')}</span>
</TabsTrigger>
<TabsTrigger
value='settings'
className='h-auto gap-2 rounded-lg px-3 py-2'
>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>
{t('Settings & Preferences')}
</span>
<span className='sm:hidden'>{t('Settings')}</span>
</TabsTrigger>
</TabsList>
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
<TabsContent value='settings' className='mt-4 sm:mt-6'>
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
</Tabs>
<TabsContent value='settings' className='mt-4 sm:mt-6'>
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
</Tabs>
</TitledCard>
)
}
+38 -41
View File
@@ -1,6 +1,6 @@
import { useAuthStore } from '@/stores/auth-store'
import { useStatus } from '@/hooks/use-status'
import { AppHeader, Main } from '@/components/layout'
import { Main } from '@/components/layout'
import {
CardStaggerContainer,
CardStaggerItem,
@@ -28,47 +28,44 @@ export function Profile() {
const canConfigureSidebar = permissions?.sidebar_settings !== false
return (
<>
<AppHeader />
<Main>
<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>
<Main>
<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-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-4 sm:space-y-6 xl:sticky xl:top-6'>
{checkinEnabled && (
<CheckinCalendarCard
checkinEnabled={checkinEnabled}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
/>
)}
{canConfigureSidebar && <SidebarModulesCard />}
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div>
<CardStaggerItem>
<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>
</CardStaggerItem>
</CardStaggerContainer>
</div>
</Main>
</>
<div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
{checkinEnabled && (
<CheckinCalendarCard
checkinEnabled={checkinEnabled}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
/>
)}
{canConfigureSidebar && <SidebarModulesCard />}
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
</div>
</Main>
)
}