mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
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:
co-authored by
t0ng7u
QuentinHsu
parent
dac55f0fde
commit
8b2b03d276
+18
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+105
-36
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user