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
+18 -6
View File
@@ -1,4 +1,5 @@
import { useNotifications } from '@/hooks/use-notifications'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { ConfigDrawer } from '@/components/config-drawer'
import { LanguageSwitcher } from '@/components/language-switcher'
@@ -10,6 +11,7 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import { Header } from './header'
import { TopNav } from './top-nav'
import { WorkspaceSwitcher } from './workspace-switcher'
/**
* General application Header component
@@ -87,20 +89,30 @@ export function AppHeader({
// Prioritize dynamically generated links from backend
const dynamicLinks = useTopNavLinks()
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
const sidebarData = useSidebarData()
// Notifications hook
const notifications = useNotifications()
// Determine left content: custom content > navigation bar > null
const leftSection =
leftContent || (showTopNav ? <TopNav links={links} /> : null)
return (
<>
<Header>
{leftSection}
<WorkspaceSwitcher
variant='inline'
workspaces={sidebarData.workspaces}
/>
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{rightContent ?? (
<div className='ms-auto flex items-center space-x-4'>
<div className='ms-auto flex items-center gap-1 sm:gap-2'>
{showTopNav && (
<div className='me-1 hidden lg:block'>
<TopNav links={links} />
</div>
)}
{showSearch && <Search />}
{showNotifications && (
<NotificationButton
@@ -6,15 +6,9 @@ import { ROLE } from '@/lib/roles'
import { useLayout } from '@/context/layout-provider'
import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { useSidebarData } from '@/hooks/use-sidebar-data'
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarRail,
} from '@/components/ui/sidebar'
import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
import { getNavGroupsForPath } from '../lib/workspace-registry'
import { NavGroup } from './nav-group'
import { WorkspaceSwitcher } from './workspace-switcher'
/**
* Application sidebar component
@@ -51,10 +45,7 @@ export function AppSidebar() {
return (
<Sidebar collapsible={collapsible} variant={variant}>
<SidebarHeader>
<WorkspaceSwitcher workspaces={sidebarData.workspaces} />
</SidebarHeader>
<SidebarContent>
<SidebarContent className='py-2'>
{currentNavGroups.map((props) => {
const key = props.id || props.title
return <NavGroup key={key} {...props} />
@@ -6,6 +6,7 @@ import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { AnimatedOutlet } from '@/components/page-transition'
import { SkipToMain } from '@/components/skip-to-main'
import { WorkspaceProvider } from '../context/workspace-context'
import { AppHeader } from './app-header'
import { AppSidebar } from './app-sidebar'
type AuthenticatedLayoutProps = {
@@ -19,18 +20,21 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
<LayoutProvider>
<SearchProvider>
<WorkspaceProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
<SkipToMain />
<AppSidebar />
<SidebarInset
className={cn(
'@container/content',
'h-svh',
'peer-data-[variant=inset]:h-[calc(100svh-(var(--spacing)*4))]'
)}
>
{props.children ?? <AnimatedOutlet />}
</SidebarInset>
<AppHeader />
<div className='flex min-h-0 w-full flex-1'>
<AppSidebar />
<SidebarInset
className={cn(
'@container/content',
'h-[calc(100svh-var(--app-header-height,0px))]',
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
)}
>
{props.children ?? <AnimatedOutlet />}
</SidebarInset>
</div>
</SidebarProvider>
</WorkspaceProvider>
</SearchProvider>
@@ -53,14 +53,17 @@ function ChatMenuItem({
if (preset.type === 'web') {
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild isActive={active}>
<Link
to='/chat/$chatId'
params={{ chatId: preset.id }}
onClick={onNavigate}
>
<span>{preset.name}</span>
</Link>
<SidebarMenuSubButton
isActive={active}
render={
<Link
to='/chat/$chatId'
params={{ chatId: preset.id }}
onClick={onNavigate}
/>
}
>
<span>{preset.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
@@ -92,10 +95,10 @@ function DropdownPresetItem({
}) {
if (preset.type === 'web') {
return (
<DropdownMenuItem asChild>
<Link to='/chat/$chatId' params={{ chatId: preset.id }}>
{preset.name}
</Link>
<DropdownMenuItem
render={<Link to='/chat/$chatId' params={{ chatId: preset.id }} />}
>
{preset.name}
</DropdownMenuItem>
)
}
@@ -187,12 +190,12 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon className='h-4 w-4' />}
<span>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
</SidebarMenuButton>
<DropdownMenuTrigger
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon className='h-4 w-4' />}
<span>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 opacity-70' />
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{visiblePresets.map((preset) => (
@@ -218,40 +221,39 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
// Expanded state - render collapsible menu
return (
<Collapsible
asChild
defaultOpen={normalizedHref.startsWith('/chat')}
className='group/collapsible'
render={<SidebarMenuItem />}
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{visiblePresets.map((preset) => (
<ChatMenuItem
key={preset.id}
preset={preset}
active={normalizedHref === `/chat/${preset.id}`}
onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)}
/>
))}
{hasKeyDependentPresets && isKeyPending && (
<SidebarMenuSubItem>
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
<CollapsibleTrigger
className='group/collapsible-trigger'
render={<SidebarMenuButton />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{visiblePresets.map((preset) => (
<ChatMenuItem
key={preset.id}
preset={preset}
active={normalizedHref === `/chat/${preset.id}`}
onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)}
/>
))}
{hasKeyDependentPresets && isKeyPending && (
<SidebarMenuSubItem>
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
+7 -2
View File
@@ -67,7 +67,7 @@ function ProjectAttribution(props: { currentYear: number }) {
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 font-medium transition-colors hover:text-foreground'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('New API')}
</a>
@@ -152,7 +152,12 @@ export function Footer(props: FooterProps) {
if (footerHtml) {
return (
<footer className={cn('border-border/40 relative z-10 border-t', props.className)}>
<footer
className={cn(
'border-border/40 relative z-10 border-t',
props.className
)}
>
<div className='mx-auto w-full max-w-6xl px-6 py-5'>
<div className='bg-muted/20 border-border/50 flex flex-col items-center justify-between gap-4 rounded-2xl border px-4 py-4 backdrop-blur-sm sm:flex-row sm:px-5'>
<div
+6 -5
View File
@@ -1,5 +1,4 @@
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
type HeaderProps = React.HTMLAttributes<HTMLElement>
@@ -7,12 +6,14 @@ type HeaderProps = React.HTMLAttributes<HTMLElement>
export function Header({ className, children, ...props }: HeaderProps) {
return (
<header
className={cn('bg-background z-50 h-16 shrink-0 border-b', className)}
className={cn(
'sticky top-0 z-40 h-[var(--app-header-height,3rem)] w-full shrink-0 bg-transparent',
className
)}
{...props}
>
<div className='flex h-full items-center gap-3 p-4 sm:gap-4'>
<SidebarTrigger variant='outline' />
<Separator orientation='vertical' className='h-6' />
<div className='flex h-full items-center gap-1.5 px-2 sm:gap-2 sm:px-3'>
<SidebarTrigger variant='ghost' className='size-8' />
{children}
</div>
</header>
@@ -137,10 +137,13 @@ interface MobileSignInButtonProps {
function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) {
const { t } = useTranslation()
return (
<Button variant='secondary' size='sm' asChild className='h-10 w-full'>
<Link to='/sign-in' onClick={onNavigate}>
{t('Sign in')}
</Link>
<Button
variant='secondary'
size='sm'
className='h-10 w-full'
render={<Link to='/sign-in' onClick={onNavigate} />}
>
{t('Sign in')}
</Button>
)
}
+67 -59
View File
@@ -10,6 +10,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -44,8 +45,10 @@ export function NavGroup({ title, items }: NavGroupProps) {
const href = useLocation({ select: (location) => location.href })
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroup className='px-2 py-1'>
<SidebarGroupLabel className='text-muted-foreground/70 px-2 text-[11px] font-medium tracking-wider uppercase'>
{title}
</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const key = `${item.title}-${item.url || item.type}`
@@ -102,15 +105,13 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={checkIsActive(href, item)}
tooltip={item.title}
render={<Link to={item.url} onClick={() => setOpenMobile(false)} />}
>
<Link to={item.url} onClick={() => setOpenMobile(false)}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</Link>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</SidebarMenuButton>
</SidebarMenuItem>
)
@@ -142,39 +143,38 @@ function SidebarMenuCollapsible({
return (
<Collapsible
asChild
open={isOpen}
onOpenChange={setIsOpen}
className='group/collapsible'
render={<SidebarMenuItem />}
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={checkIsActive(href, subItem)}
>
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
<CollapsibleTrigger
className='group/collapsible-trigger'
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[panel-open]/collapsible-trigger:rotate-90' />
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
isActive={checkIsActive(href, subItem)}
render={
<Link to={subItem.url} onClick={() => setOpenMobile(false)} />
}
>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
@@ -192,36 +192,44 @@ function SidebarMenuCollapsedDropdown({
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
<DropdownMenuTrigger
className='group/dropdown-trigger'
render={
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
/>
}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
<DropdownMenuGroup>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem
key={`${sub.title}-${sub.url}`}
render={
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
/>
}
>
{sub.icon && <sub.icon />}
<span className='max-w-52 text-wrap'>{sub.title}</span>
{sub.badge && (
<span className='ms-auto text-xs'>{sub.badge}</span>
)}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
@@ -180,9 +180,9 @@ export function PublicHeader(props: PublicHeaderProps) {
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
asChild
render={<Link to='/sign-in' />}
>
<Link to='/sign-in'>{t('Sign in')}</Link>
{t('Sign in')}
</Button>
)}
</>
@@ -16,7 +16,7 @@ type PublicLayoutProps = {
export function PublicLayout(props: PublicLayoutProps) {
return (
<div className='bg-background text-foreground relative min-h-svh overflow-hidden'>
<div className='bg-background text-foreground relative min-h-svh overflow-x-clip'>
<PublicHeader
navContent={props.navContent}
navLinks={props.navLinks}
@@ -5,7 +5,6 @@ import {
type ReactElement,
type ReactNode,
} from 'react'
import { AppHeader } from './app-header'
import { Main } from './main'
import { PageFooterProvider } from './page-footer'
@@ -46,7 +45,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
)
let title: ReactNode = null
let description: ReactNode = null
let actions: ReactNode = null
let content: ReactNode = null
let breadcrumb: ReactNode = null
@@ -55,8 +53,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
if (!isValidElement(node)) return
const child = node as ReactElement<SlotProps>
if (child.type === SectionPageLayoutTitle) title = child.props.children
else if (child.type === SectionPageLayoutDescription)
description = child.props.children
else if (child.type === SectionPageLayoutActions)
actions = child.props.children
else if (child.type === SectionPageLayoutContent)
@@ -67,21 +63,16 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
return (
<PageFooterProvider container={footerContainer}>
<AppHeader />
<Main>
<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='shrink-0 px-3 pt-3 pb-2.5 sm:px-4 sm:pt-5 sm:pb-3'>
{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='truncate text-base font-bold tracking-tight sm:text-lg'>
{title}
</h2>
{description != null && (
<p className='text-muted-foreground line-clamp-2 max-sm:text-xs sm:text-sm'>
{description}
</p>
)}
</div>
{actions != null && (
<div className='flex shrink-0 flex-wrap items-center gap-2 sm:gap-x-4'>
@@ -91,7 +82,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div>
</div>
<div className='min-h-0 flex-1 overflow-auto px-3 pb-3 sm:px-4 sm:pb-4'>
<div className='min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'>
{content}
</div>
+27 -24
View File
@@ -37,34 +37,37 @@ export function TopNav({ className, links, ...props }: TopNavProps) {
{/* 移动端下拉菜单 */}
<div className='lg:hidden'>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size='icon' variant='outline' className='size-7'>
<Menu />
</Button>
<DropdownMenuTrigger
render={<Button size='icon' variant='outline' className='size-7' />}
>
<Menu />
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='start'>
{normalizedLinks.map(
({ title, href, isActive, disabled, external }) => (
<DropdownMenuItem key={`${title}-${href}`} asChild>
{external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className={!isActive ? 'text-muted-foreground' : ''}
>
{title}
</a>
) : (
<Link
to={href}
className={!isActive ? 'text-muted-foreground' : ''}
disabled={disabled}
>
{title}
</Link>
)}
</DropdownMenuItem>
<DropdownMenuItem
key={`${title}-${href}`}
render={
external ? (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className={!isActive ? 'text-muted-foreground' : ''}
>
{title}
</a>
) : (
<Link
to={href}
className={!isActive ? 'text-muted-foreground' : ''}
disabled={disabled}
>
{title}
</Link>
)
}
></DropdownMenuItem>
)
)}
</DropdownMenuContent>
@@ -4,11 +4,13 @@ import { ChevronsUpDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
@@ -27,6 +29,12 @@ type WorkspaceSwitcherProps = {
workspaces: Workspace[]
defaultName?: string
defaultVersion?: string
/**
* Visual layout:
* - 'sidebar': stacked card style (used inside the sidebar header).
* - 'inline': compact horizontal pill (used inside the top app bar).
*/
variant?: 'sidebar' | 'inline'
}
/**
@@ -39,6 +47,7 @@ export function WorkspaceSwitcher({
workspaces,
defaultName = 'New API',
defaultVersion,
variant = 'sidebar',
}: WorkspaceSwitcherProps) {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -121,6 +130,88 @@ export function WorkspaceSwitcher({
}
const canSwitchWorkspace = availableWorkspaces.length > 1
const renderWorkspaceList = () => (
<DropdownMenuGroup>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
>
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img src={logo} alt='Logo' className='size-full object-cover' />
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)
if (variant === 'inline') {
const inlineLogo =
activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
<div className='bg-primary text-primary-foreground flex size-5 items-center justify-center rounded-md'>
<activeWorkspace.logo className='size-3' />
</div>
) : (
<div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-md object-cover'
/>
</div>
)
const inlineButtonClass = cn(
'inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium text-foreground outline-none select-none transition-colors',
'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/40',
'data-popup-open:bg-accent'
)
if (!canSwitchWorkspace) {
return (
<div
className={cn(
inlineButtonClass,
'cursor-default hover:bg-transparent'
)}
>
{inlineLogo}
<span className='max-w-[12rem] truncate'>{activeWorkspace.name}</span>
</div>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger className={inlineButtonClass}>
{inlineLogo}
<span className='max-w-[12rem] truncate'>{activeWorkspace.name}</span>
<ChevronsUpDown className='text-muted-foreground size-3.5' />
</DropdownMenuTrigger>
<DropdownMenuContent
className='min-w-56 rounded-lg'
align='start'
side='bottom'
sideOffset={6}
>
{renderWorkspaceList()}
</DropdownMenuContent>
</DropdownMenu>
)
}
const workspaceButtonContent = (
<>
{activeWorkspace.id === WORKSPACE_IDS.SYSTEM_SETTINGS ? (
@@ -151,54 +242,32 @@ export function WorkspaceSwitcher({
<SidebarMenuItem>
{canSwitchWorkspace ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
{workspaceButtonContent}
</SidebarMenuButton>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size='lg'
className='data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground'
/>
}
>
{workspaceButtonContent}
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
className='w-(--anchor-width) min-w-56 rounded-lg'
align='start'
side={isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuLabel className='text-muted-foreground text-xs'>
{t('Workspaces')}
</DropdownMenuLabel>
{availableWorkspaces.map((workspace, index) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace)}
className='gap-2 p-2'
>
{index === 0 ? (
<div className='flex size-6 items-center justify-center overflow-hidden rounded-sm border'>
<img
src={logo}
alt='Logo'
className='size-full object-cover'
/>
</div>
) : (
<div className='flex size-6 items-center justify-center rounded-sm border'>
<workspace.logo className='size-4 shrink-0' />
</div>
)}
{workspace.name}
</DropdownMenuItem>
))}
{renderWorkspaceList()}
</DropdownMenuContent>
</DropdownMenu>
) : (
<SidebarMenuButton
asChild
size='lg'
className='cursor-default hover:bg-transparent hover:text-sidebar-foreground active:bg-transparent active:text-sidebar-foreground'
className='hover:text-sidebar-foreground active:text-sidebar-foreground cursor-default hover:bg-transparent active:bg-transparent'
render={<div />}
>
<div>{workspaceButtonContent}</div>
{workspaceButtonContent}
</SidebarMenuButton>
)}
</SidebarMenuItem>