refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
@@ -0,0 +1,150 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ConfigDrawer } from '@/components/config-drawer'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { useNotifications } from '@/hooks/use-notifications'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import { Header } from './header'
import { SystemBrand } from './system-brand'
import { TopNav } from './top-nav'
/**
* General application Header component
* Integrates navigation bar, search, configuration and profile functions
*
* @example
* // Basic usage
* <AppHeader />
*
* @example
* // Custom navigation links
* <AppHeader navLinks={customLinks} />
*
* @example
* // Hide navigation bar and search box
* <AppHeader showTopNav={false} showSearch={false} />
*
* @example
* // Fully customize left and right content
* <AppHeader
* leftContent={<CustomLeft />}
* rightContent={<CustomRight />}
* />
*/
type AppHeaderProps = {
/**
* Custom navigation links, uses default global navigation or dynamically generated from backend if not provided
*/
navLinks?: TopNavLink[]
/**
* Whether to show top navigation bar
* @default true
*/
showTopNav?: boolean
/**
* Left content, overrides TopNav if provided
*/
leftContent?: React.ReactNode
/**
* Whether to show search box
* @default true
*/
showSearch?: boolean
/**
* Custom right content, overrides default right content if provided
*/
rightContent?: React.ReactNode
/**
* Whether to show notification button
* @default true
*/
showNotifications?: boolean
/**
* Whether to show config drawer
* @default true
*/
showConfigDrawer?: boolean
/**
* Whether to show profile dropdown
* @default true
*/
showProfileDropdown?: boolean
}
export function AppHeader({
navLinks = defaultTopNavLinks,
showTopNav = true,
leftContent,
showSearch = true,
rightContent,
showNotifications = true,
showConfigDrawer = true,
showProfileDropdown = true,
}: AppHeaderProps) {
// Prioritize dynamically generated links from backend
const dynamicLinks = useTopNavLinks()
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
// Notifications hook
const notifications = useNotifications()
return (
<>
<Header>
<SystemBrand variant='inline' />
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{rightContent ?? (
<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 && (
<NotificationPopover
open={notifications.popoverOpen}
onOpenChange={notifications.setPopoverOpen}
unreadCount={notifications.unreadCount}
activeTab={notifications.activeTab}
onTabChange={notifications.setActiveTab}
notice={notifications.notice}
announcements={notifications.announcements}
loading={notifications.loading}
/>
)}
<LanguageSwitcher />
{showConfigDrawer && <ConfigDrawer />}
{showProfileDropdown && <ProfileDropdown />}
</div>
)}
</Header>
</>
)
}
@@ -0,0 +1,77 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar'
import { useLayout } from '@/context/layout-provider'
import { useSidebarView } from '@/hooks/use-sidebar-view'
import { MOTION_TRANSITION, MOTION_VARIANTS } from '@/lib/motion'
import { NavGroup } from './nav-group'
import { SidebarViewHeader } from './sidebar-view-header'
/**
* Application sidebar.
*
* Adopts the Vercel / Cloudflare "drill-in" pattern: the URL drives
* which sidebar *view* is rendered. Clicking a top-level entry like
* `System Settings` swaps the sidebar to a contextual workspace —
* with a `← Back to Dashboard` affordance — instead of stacking the
* sub-navigation inside the root tree.
*
* Architecture:
* - View resolution + filtering: {@link useSidebarView}
* - View registry: `layout/lib/sidebar-view-registry.ts`
* - Per-view header: {@link SidebarViewHeader}
*
* Adding a new nested view only requires registering a {@link SidebarView}
* in the registry; this component requires no changes.
*/
export function AppSidebar() {
const { collapsible, variant } = useLayout()
const { key, view, navGroups } = useSidebarView()
const shouldReduce = useReducedMotion()
return (
<Sidebar collapsible={collapsible} variant={variant}>
{view && <SidebarViewHeader view={view} />}
<SidebarContent className='py-2'>
<AnimatePresence mode='wait' initial={false}>
<motion.div
key={key}
initial={
shouldReduce ? false : MOTION_VARIANTS.sidebarSlide.initial
}
animate={MOTION_VARIANTS.sidebarSlide.animate}
exit={shouldReduce ? undefined : MOTION_VARIANTS.sidebarSlide.exit}
transition={MOTION_TRANSITION.fast}
className='flex flex-col'
>
{navGroups.map((props) => (
<NavGroup key={props.id || props.title} {...props} />
))}
</motion.div>
</AnimatePresence>
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}
@@ -0,0 +1,60 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AnimatedOutlet } from '@/components/page-transition'
import { SkipToMain } from '@/components/skip-to-main'
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
import { LayoutProvider } from '@/context/layout-provider'
import { SearchProvider } from '@/context/search-provider'
import { getCookie } from '@/lib/cookies'
import { cn } from '@/lib/utils'
import { AppHeader } from './app-header'
import { AppSidebar } from './app-sidebar'
type AuthenticatedLayoutProps = {
children?: React.ReactNode
}
export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
const defaultOpen = getCookie('sidebar_state') !== 'false'
return (
<LayoutProvider>
<SearchProvider>
<SidebarProvider defaultOpen={defaultOpen} className='flex-col'>
<SkipToMain />
<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))]',
'min-h-0 overflow-hidden',
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
)}
>
{props.children ?? <AnimatedOutlet />}
</SidebarInset>
</div>
</SidebarProvider>
</SearchProvider>
</LayoutProvider>
)
}
@@ -0,0 +1,286 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link, useLocation } from '@tanstack/react-router'
import { ExternalLink, Loader2, ChevronRight } from 'lucide-react'
import { useMemo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
import { fetchActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
import {
chatLinkRequiresApiKey,
resolveChatUrl,
type ChatPreset,
} from '@/features/chat/lib/chat-links'
import { normalizeHref } from '../lib/url-utils'
import type { NavChatPresets } from '../types'
/**
* Sub-menu item for a single chat preset
*/
function ChatMenuItem({
preset,
active,
loading,
onOpen,
onNavigate,
}: {
preset: ChatPreset
active: boolean
loading: boolean
onOpen: (preset: ChatPreset) => void | Promise<void>
onNavigate: () => void
}) {
if (preset.type === 'web') {
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={active}
render={
<Link
to='/chat/$chatId'
params={{ chatId: preset.id }}
onClick={onNavigate}
/>
}
>
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
}
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton
onClick={() => {
if (!loading) void onOpen(preset)
}}
aria-disabled={loading ? 'true' : undefined}
isActive={false}
className='justify-between'
>
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
{preset.name}
</span>
{loading ? (
<Loader2 className='h-4 w-4 shrink-0 animate-spin' />
) : (
<ExternalLink className='h-4 w-4 shrink-0' />
)}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
}
/**
* Dropdown menu item for a single chat preset
*/
function DropdownPresetItem({
preset,
loading,
onOpen,
}: {
preset: ChatPreset
loading: boolean
onOpen: (preset: ChatPreset) => void | Promise<void>
}) {
if (preset.type === 'web') {
return (
<DropdownMenuItem
render={<Link to='/chat/$chatId' params={{ chatId: preset.id }} />}
>
{preset.name}
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
disabled={loading}
onClick={() => {
if (!loading) void onOpen(preset)
}}
>
{preset.name}
{loading ? (
<Loader2 className='ml-auto h-4 w-4 animate-spin opacity-70' />
) : (
<ExternalLink className='ml-auto h-4 w-4 opacity-70' />
)}
</DropdownMenuItem>
)
}
/**
* Dynamic chat presets navigation item
*/
export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
const { t } = useTranslation()
const { chatPresets, serverAddress } = useChatPresets()
const { state, isMobile, setOpenMobile } = useSidebar()
const href = useLocation({ select: (location) => location.href })
const [loadingPresetId, setLoadingPresetId] = useState<string | null>(null)
const loadingPresetIdRef = useRef<string | null>(null)
const visiblePresets = useMemo(
() => chatPresets.filter((preset) => preset.type !== 'fluent'),
[chatPresets]
)
const handleOpenExternal = useCallback(
async (preset: ChatPreset) => {
if (preset.type === 'web') return
const needsKey = chatLinkRequiresApiKey(preset.url)
let activeKey: string | undefined
if (needsKey && loadingPresetIdRef.current) {
toast.info(t('Preparing your chat link, please try again in a moment.'))
return
}
if (needsKey) {
loadingPresetIdRef.current = preset.id
setLoadingPresetId(preset.id)
try {
activeKey = await fetchActiveChatKey()
} catch (error) {
const message =
error instanceof Error
? error.message
: t(
'Unable to prepare chat link. Please ensure you have an enabled API key.'
)
toast.error(message)
return
} finally {
loadingPresetIdRef.current = null
setLoadingPresetId(null)
}
}
const url = resolveChatUrl({
template: preset.url,
apiKey: needsKey ? activeKey : undefined,
serverAddress,
})
if (!url) {
toast.error(t('Invalid chat link. Please contact the administrator.'))
return
}
if (typeof window === 'undefined') return
window.open(url, '_blank', 'noopener')
setOpenMobile(false)
},
[serverAddress, setOpenMobile, t]
)
const normalizedHref = normalizeHref(href)
// Don't render if no visible presets
if (visiblePresets.length === 0) {
return null
}
// Collapsed state on non-mobile - render dropdown menu
if (state === 'collapsed' && !isMobile) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon className='h-4 w-4 shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
<ChevronRight className='ms-auto h-4 w-4 shrink-0 opacity-70' />
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{visiblePresets.map((preset) => (
<DropdownPresetItem
key={preset.id}
preset={preset}
loading={loadingPresetId === preset.id}
onOpen={handleOpenExternal}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
// Expanded state - render collapsible menu
return (
<Collapsible
defaultOpen={normalizedHref.startsWith('/chat')}
className='group/collapsible'
render={<SidebarMenuItem />}
>
<CollapsibleTrigger
className='group/collapsible-trigger'
render={<SidebarMenuButton />}
>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
<ChevronRight className='ms-auto size-4 shrink-0 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}`}
loading={loadingPresetId === preset.id}
onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)}
/>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
@@ -0,0 +1,308 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { Fragment, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import { cn } from '@/lib/utils'
interface FooterLink {
text: string
href: string
}
interface FooterColumnProps {
title: string
links: FooterLink[]
}
interface FooterProps {
logo?: string
name?: string
columns?: FooterColumnProps[]
copyright?: string
className?: string
}
const NEW_API_FOOTER_ATTRIBUTION_KEY = [
'footer',
'new' + 'api',
'projectAttributionSuffix',
].join('.')
function FooterLinkItem(props: { link: FooterLink }) {
const { t } = useTranslation()
const isExternal = props.link.href.startsWith('http')
const label = t(props.link.text)
if (isExternal) {
return (
<a
href={props.link.href}
target='_blank'
rel='noopener noreferrer'
className='text-muted-foreground hover:text-foreground text-sm transition-colors duration-200'
>
{label}
</a>
)
}
return (
<Link
to={props.link.href}
className='text-muted-foreground hover:text-foreground text-sm transition-colors duration-200'
>
{label}
</Link>
)
}
// Renders User Agreement / Privacy Policy links inline with the parent's
// copyright row when either is configured in System Settings → Site. Emits
// fragmented siblings so the parent flex container's gap controls spacing.
function LegalLinks(props: { leadingSeparator?: boolean }) {
const { t } = useTranslation()
const { status } = useStatus()
const items: { key: string; label: string; href: string }[] = []
if (status?.user_agreement_enabled) {
items.push({
key: 'user-agreement',
label: t('User Agreement'),
href: '/user-agreement',
})
}
if (status?.privacy_policy_enabled) {
items.push({
key: 'privacy-policy',
label: t('Privacy Policy'),
href: '/privacy-policy',
})
}
if (items.length === 0) {
return null
}
return (
<>
{items.map((item, index) => (
<Fragment key={item.key}>
{(props.leadingSeparator || index > 0) && (
<span aria-hidden='true' className='text-muted-foreground/30'>
·
</span>
)}
<Link
to={item.href}
className='hover:text-foreground transition-colors duration-200'
>
{item.label}
</Link>
</Fragment>
))}
</>
)
}
// inline=true returns just the inner span for composition in a parent flex
// row. inline=false wraps in a centered/right-aligned div (default).
function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
const { t } = useTranslation()
const content = (
<span className='text-muted-foreground/45'>
&copy; {props.currentYear}{' '}
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('New API')}
</a>
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
</span>
)
if (props.inline) {
return content
}
return (
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'>
{content}
</div>
)
}
export function Footer(props: FooterProps) {
const { t } = useTranslation()
const {
systemName,
logo: systemLogo,
footerHtml,
demoSiteEnabled,
} = useSystemConfig()
const displayLogo = systemLogo || props.logo || '/logo.png'
const displayName = systemName || props.name || 'New API'
const isDemoSiteMode = Boolean(demoSiteEnabled)
const currentYear = new Date().getFullYear()
const fallbackColumns = useMemo<FooterColumnProps[]>(
() => [
{
title: t('footer.columns.about.title'),
links: [
{
text: t('footer.columns.about.links.aboutProject'),
href: 'https://docs.newapi.pro/wiki/project-introduction/',
},
{
text: t('footer.columns.about.links.contact'),
href: 'https://docs.newapi.pro/support/community-interaction/',
},
{
text: t('footer.columns.about.links.features'),
href: 'https://docs.newapi.pro/wiki/features-introduction/',
},
],
},
{
title: t('footer.columns.docs.title'),
links: [
{
text: t('footer.columns.docs.links.quickStart'),
href: 'https://docs.newapi.pro/getting-started/',
},
{
text: t('footer.columns.docs.links.installation'),
href: 'https://docs.newapi.pro/installation/',
},
{
text: t('footer.columns.docs.links.apiDocs'),
href: 'https://docs.newapi.pro/api/',
},
],
},
{
title: t('footer.columns.related.title'),
links: [
{
text: t('footer.columns.related.links.oneApi'),
href: 'https://github.com/songquanpeng/one-api',
},
{
text: t('footer.columns.related.links.midjourney'),
href: 'https://github.com/novicezk/midjourney-proxy',
},
{
text: t('footer.columns.related.links.newApiKeyTool'),
href: 'https://github.com/Calcium-Ion/new-api-key-tool',
},
],
},
],
[t]
)
const displayColumns = props.columns ?? fallbackColumns
if (footerHtml) {
return (
<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
className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
dangerouslySetInnerHTML={{ __html: footerHtml }}
/>
<div className='border-border/60 text-muted-foreground/45 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
<LegalLinks />
<ProjectAttribution currentYear={currentYear} inline />
</div>
</div>
</div>
</footer>
)
}
return (
<footer
className={cn('border-border/40 relative z-10 border-t', props.className)}
>
<div className='mx-auto max-w-6xl px-6 py-12 md:py-16'>
<div className='flex flex-col justify-between gap-10 md:flex-row md:gap-16'>
{/* Brand column */}
<div className='shrink-0'>
<Link to='/' className='group flex items-center gap-2.5'>
<img
src={displayLogo}
alt={displayName}
className='size-7 rounded-lg object-contain'
/>
<span className='text-sm font-semibold tracking-tight'>
{displayName}
</span>
</Link>
<p className='text-muted-foreground/60 mt-3 max-w-[200px] text-xs leading-relaxed'>
{t('Powerful API Management Platform')}
</p>
</div>
{/* Links columns */}
{isDemoSiteMode && (
<div className='grid grid-cols-3 gap-8 md:gap-16'>
{displayColumns.map((column, index) => (
<div key={index}>
<p className='text-muted-foreground/50 mb-3 text-xs font-medium tracking-wider uppercase'>
{t(column.title)}
</p>
<ul className='space-y-2.5'>
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<FooterLinkItem link={link} />
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
{/* Copyright + optional legal links inline on the left, project
attribution on the right; wraps on narrow screens. */}
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-x-3 gap-y-2 border-t pt-6 sm:flex-row'>
<div className='text-muted-foreground/40 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs sm:justify-start'>
<span>
&copy; {currentYear} {displayName}.{' '}
{props.copyright ?? t('footer.defaultCopyright')}
</span>
<LegalLinks leadingSeparator />
</div>
<ProjectAttribution currentYear={currentYear} />
</div>
</div>
</footer>
)
}
@@ -0,0 +1,65 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cva, type VariantProps } from 'class-variance-authority'
import React from 'react'
import { cn } from '@/lib/utils'
const glowVariants = cva('absolute w-full', {
variants: {
variant: {
top: 'top-0',
above: '-top-[128px]',
bottom: 'bottom-0',
below: '-bottom-[128px]',
center: 'top-[50%]',
},
},
defaultVariants: {
variant: 'top',
},
})
export interface GlowProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof glowVariants> {}
export function Glow({ className, variant, ...props }: GlowProps) {
return (
<div
data-slot='glow'
className={cn(glowVariants({ variant }), className)}
{...props}
>
<div
className={cn(
'absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 scale-[2.5] rounded-[50%] bg-radial from-amber-500/60 from-10% to-amber-500/0 to-60% opacity-40 sm:h-[512px] dark:opacity-80',
variant === 'center' && '-translate-y-1/2'
)}
/>
<div
className={cn(
'absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 scale-200 rounded-[50%] bg-radial from-yellow-400/50 from-10% to-yellow-400/0 to-60% opacity-30 sm:h-[256px] dark:opacity-70',
variant === 'center' && '-translate-y-1/2'
)}
/>
</div>
)
}
@@ -0,0 +1,51 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cn } from '@/lib/utils'
interface HeaderLogoProps {
src: string
alt?: string
loading: boolean
logoLoaded: boolean
className?: string
}
/**
* Logo component for header with loading state
* Shows image only when fully loaded for smooth UX
*/
export function HeaderLogo({
src,
alt = 'logo',
loading,
logoLoaded,
className,
}: HeaderLogoProps) {
return (
<img
src={src}
alt={alt}
className={cn(
'h-6 w-6 rounded-full transition-opacity duration-200',
!loading && logoLoaded ? 'opacity-100' : 'opacity-0',
className
)}
/>
)
}
@@ -0,0 +1,39 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { SidebarTrigger } from '@/components/ui/sidebar'
import { cn } from '@/lib/utils'
type HeaderProps = React.HTMLAttributes<HTMLElement>
export function Header({ className, children, ...props }: HeaderProps) {
return (
<header
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-1.5 px-2 sm:gap-2 sm:px-3'>
<SidebarTrigger variant='ghost' className='size-8' />
{children}
</div>
</header>
)
}
@@ -0,0 +1,62 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
export interface LogoProps extends React.HTMLAttributes<HTMLDivElement> {
image: React.ComponentType<React.SVGProps<SVGSVGElement>>
name: string
version?: string
width?: number
height?: number
showName?: boolean
badge?: string
}
export function Logo({
className,
image: SvgImage,
name,
version,
width = 24,
height = 24,
showName = true,
badge,
...props
}: LogoProps) {
return (
<div
data-slot='logo'
className={cn('flex items-center gap-2 text-sm font-medium', className)}
{...props}
>
<SvgImage
width={width}
height={height}
aria-hidden='true'
className='max-h-full max-w-full opacity-70'
/>
<span className={cn(!showName && 'sr-only')}>{name}</span>
{version && <span className='text-muted-foreground'>{version}</span>}
{badge && <Badge variant='secondary'>{badge}</Badge>}
</div>
)
}
@@ -0,0 +1,37 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cn } from '@/lib/utils'
type MainProps = React.HTMLAttributes<HTMLElement> & {
fluid?: boolean
}
export function Main({ className, fluid = true, ...props }: MainProps) {
return (
<main
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden',
!fluid &&
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
className
)}
{...props}
/>
)
}
@@ -0,0 +1,296 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { X, User, Wallet, LogOut } from 'lucide-react'
import { AnimatePresence, motion, type Variants } from 'motion/react'
import { useTranslation } from 'react-i18next'
import { SignOutDialog } from '@/components/sign-out-dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import useDialogState from '@/hooks/use-dialog'
import { useUserDisplay } from '@/hooks/use-user-display'
import type { AuthUser } from '@/stores/auth-store'
import { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from '../constants'
import type { TopNavLink } from '../types'
/**
* Brand logo component with skeleton loading
*/
interface BrandLogoProps {
homeUrl: string
displayLogo: React.ReactNode
displaySiteName: string
loading: boolean
logoLoaded: boolean
onClick?: () => void
}
function BrandLogo({
homeUrl,
displayLogo,
displaySiteName,
loading,
logoLoaded,
onClick,
}: BrandLogoProps) {
return (
<Link
to={homeUrl}
className='flex items-center gap-2 text-xl font-bold'
onClick={onClick}
>
<div className='relative h-6 w-6'>
{loading || !logoLoaded ? (
<Skeleton className='absolute inset-0 rounded-full' />
) : null}
{displayLogo}
</div>
{loading ? <Skeleton className='h-5 w-20' /> : displaySiteName}
</Link>
)
}
/**
* Mobile user profile section with navigation links
*/
interface MobileUserProfileProps {
user: AuthUser | null
onNavigate?: () => void
}
function MobileUserProfile({ user, onNavigate }: MobileUserProfileProps) {
const { t } = useTranslation()
const [signOutOpen, setSignOutOpen] = useDialogState()
const { displayName, initials, roleLabel } = useUserDisplay(user)
if (!user) return null
return (
<>
{/* User info section - compact style matching navigation */}
<div className='flex flex-col text-sm'>
{/* User header - simplified */}
<div className='border-border flex items-center gap-2.5 border-b p-2.5'>
<Avatar className='size-9'>
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
<AvatarFallback className='text-xs'>{initials}</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-col gap-0.5 overflow-hidden'>
<p className='text-foreground truncate font-medium'>
{displayName}
</p>
<div className='flex items-center gap-1.5'>
<span className='text-muted-foreground text-xs'>{roleLabel}</span>
{user.group && (
<>
<span className='text-muted-foreground text-xs'>·</span>
<span className='text-muted-foreground text-xs'>
{String(user.group)}
</span>
</>
)}
</div>
</div>
</div>
{/* Navigation links - same style as top nav */}
<Link
to='/profile'
onClick={onNavigate}
className='text-primary/60 hover:text-primary/80 border-border flex items-center gap-2.5 border-b p-2.5 transition-colors'
>
<User className='size-4' />
{t('Profile')}
</Link>
<Link
to='/wallet'
onClick={onNavigate}
className='text-primary/60 hover:text-primary/80 border-border flex items-center gap-2.5 border-b p-2.5 transition-colors'
>
<Wallet className='size-4' />
{t('Wallet')}
</Link>
{/* Sign out - consistent style */}
<Button
variant='ghost'
onClick={() => setSignOutOpen(true)}
className='text-destructive hover:text-destructive/80 h-auto w-full justify-start gap-2.5 p-2.5 hover:bg-transparent'
>
<LogOut className='size-4' />
{t('Sign out')}
</Button>
</div>
<SignOutDialog open={!!signOutOpen} onOpenChange={setSignOutOpen} />
</>
)
}
/**
* Mobile sign in button for unauthenticated users
*/
interface MobileSignInButtonProps {
onNavigate?: () => void
}
function MobileSignInButton({ onNavigate }: MobileSignInButtonProps) {
const { t } = useTranslation()
return (
<Button
variant='secondary'
size='sm'
className='h-10 w-full'
render={<Link to='/sign-in' onClick={onNavigate} />}
>
{t('Sign in')}
</Button>
)
}
/**
* Mobile drawer component props
*/
export interface MobileDrawerProps {
isOpen: boolean
onClose: () => void
homeUrl: string
displayLogo: React.ReactNode
displaySiteName: string
loading: boolean
logoLoaded: boolean
mobileLinksList: TopNavLink[]
showAuthButtons: boolean
user: AuthUser | null
}
/**
* Mobile drawer component with bottom slide-up animation
* Displays navigation links and user profile section
*/
export function MobileDrawer({
isOpen,
onClose,
homeUrl,
displayLogo,
displaySiteName,
loading,
logoLoaded,
mobileLinksList,
showAuthButtons,
user,
}: MobileDrawerProps) {
const { t } = useTranslation()
return (
<AnimatePresence>
{isOpen && (
<>
{/* Overlay */}
<motion.div
className={MOBILE_DRAWER_CONFIG.overlayClassName}
initial='hidden'
animate='visible'
exit='exit'
variants={MOBILE_DRAWER_ANIMATION.overlay as Variants}
transition={{
duration: MOBILE_DRAWER_CONFIG.overlayTransitionDuration,
}}
onClick={onClose}
/>
{/* Drawer Content */}
<motion.div
className={MOBILE_DRAWER_CONFIG.drawerClassName}
initial='hidden'
animate='visible'
exit='exit'
variants={MOBILE_DRAWER_ANIMATION.drawer as Variants}
>
<div className='flex flex-col gap-4'>
{/* Header with logo and close button */}
<div className='flex items-center justify-between'>
<BrandLogo
homeUrl={homeUrl}
displayLogo={displayLogo}
displaySiteName={displaySiteName}
loading={loading}
logoLoaded={logoLoaded}
onClick={onClose}
/>
<Button
variant='ghost'
size='icon-sm'
onClick={onClose}
className='hover:text-primary cursor-pointer'
aria-label={t('Close menu')}
>
<X className='size-5' />
</Button>
</div>
{/* Navigation links */}
<motion.div
className='border-border mb-4 flex flex-col rounded-md border text-sm'
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
>
{loading ? (
<div className='flex flex-col gap-1 p-2'>
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className='h-8 w-full' />
))}
</div>
) : (
<AnimatePresence>
{mobileLinksList.map((link, index) => (
<motion.div
key={`${link.href}-${index}`}
className='border-border border-b p-2.5 last:border-b-0'
variants={MOBILE_DRAWER_ANIMATION.menuItem as Variants}
>
<Link
to={link.href}
className='text-primary/60 hover:text-primary/80 transition-colors'
onClick={onClose}
>
{link.title}
</Link>
</motion.div>
))}
</AnimatePresence>
)}
</motion.div>
{/* User profile section */}
{showAuthButtons &&
(user ? (
<MobileUserProfile user={user} onNavigate={onClose} />
) : (
<MobileSignInButton onNavigate={onClose} />
))}
</div>
</motion.div>
</>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,82 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cva, type VariantProps } from 'class-variance-authority'
import React from 'react'
import { cn } from '@/lib/utils'
const mockupVariants = cva(
'flex relative z-10 overflow-hidden shadow-2xl border border-border/70 dark:border-border/5 dark:border-t-border/15',
{
variants: {
type: {
mobile: 'rounded-4xl max-w-[350px]',
responsive: 'rounded-md',
},
},
defaultVariants: {
type: 'responsive',
},
}
)
export interface MockupProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof mockupVariants> {}
export function Mockup({ className, type, ...props }: MockupProps) {
return (
<div
data-slot='mockup'
className={cn(mockupVariants({ type, className }))}
{...props}
/>
)
}
const frameVariants = cva(
'bg-border/50 flex relative z-10 overflow-hidden rounded-2xl dark:bg-border/10',
{
variants: {
size: {
small: 'p-2',
large: 'p-4',
},
},
defaultVariants: {
size: 'small',
},
}
)
export interface MockupFrameProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof frameVariants> {}
export function MockupFrame({ className, size, ...props }: MockupFrameProps) {
return (
<div
data-slot='mockup-frame'
className={cn(frameVariants({ size, className }))}
{...props}
/>
)
}
@@ -0,0 +1,257 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import { type ReactNode, useState, useEffect } from 'react'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
import { checkIsActive } from '../lib/url-utils'
import {
type NavCollapsible,
type NavChatPresets,
type NavLink,
type NavGroup as NavGroupProps,
} from '../types'
import { ChatPresetsItem } from './chat-presets-item'
/**
* Sidebar navigation group component
* Renders a group of navigation items, supporting regular links and collapsible submenus
*/
export function NavGroup({ title, items }: NavGroupProps) {
const { state, isMobile } = useSidebar()
const href = useLocation({ select: (location) => location.href })
return (
<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}`
// Special handling: dynamic chat presets list
if (item.type === 'chat-presets') {
return <ChatPresetsItem key={key} item={item as NavChatPresets} />
}
// If no sub-items, render regular link
if (!item.items) {
return (
<SidebarMenuLink key={key} item={item as NavLink} href={href} />
)
}
// In collapsed state on non-mobile, render dropdown menu
if (state === 'collapsed' && !isMobile) {
return (
<SidebarMenuCollapsedDropdown
key={key}
item={item as NavCollapsible}
href={href}
/>
)
}
// Render collapsible menu
return (
<SidebarMenuCollapsible
key={key}
item={item as NavCollapsible}
href={href}
/>
)
})}
</SidebarMenu>
</SidebarGroup>
)
}
/**
* Navigation badge component
*/
function NavBadge({ children }: { children: ReactNode }) {
return <Badge className='shrink-0 px-1 py-0 text-xs'>{children}</Badge>
}
/**
* Sidebar menu link item
*/
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
const { setOpenMobile } = useSidebar()
return (
<SidebarMenuItem>
<SidebarMenuButton
isActive={checkIsActive(href, item)}
tooltip={item.title}
render={<Link to={item.url} onClick={() => setOpenMobile(false)} />}
>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</SidebarMenuButton>
</SidebarMenuItem>
)
}
/**
* Sidebar collapsible menu item
*/
function SidebarMenuCollapsible({
item,
href,
}: {
item: NavCollapsible
href: string
}) {
const { setOpenMobile } = useSidebar()
// 检查当前路径是否匹配子菜单项
const isSubItemActive = checkIsActive(href, item)
// 使用受控状态,初始值基于当前路径是否匹配
const [isOpen, setIsOpen] = useState(() => isSubItemActive)
// 当路径变化时,如果匹配子菜单项,自动展开父级菜单
useEffect(() => {
if (isSubItemActive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOpen(true)
}
}, [isSubItemActive])
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className='group/collapsible'
render={<SidebarMenuItem />}
>
<CollapsibleTrigger
className='group/collapsible-trigger'
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto size-4 shrink-0 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 className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
)
}
/**
* Sidebar dropdown menu item when collapsed
*/
function SidebarMenuCollapsedDropdown({
item,
href,
}: {
item: NavCollapsible
href: string
}) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
className='group/dropdown-trigger'
render={
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
/>
}
>
{item.icon && <item.icon className='shrink-0' />}
<span className='min-w-0 flex-1 truncate'>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto size-4 shrink-0 transition-transform duration-200 group-data-[popup-open]/dropdown-trigger:rotate-90' />
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<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>
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import type { TopNavLink } from '../types'
interface NavLinkItemProps {
link: TopNavLink
className?: string
}
/**
* Renders a single navigation link (internal or external)
* Handles routing and proper link attributes
*/
export function NavLinkItem({ link, className }: NavLinkItemProps) {
const linkClassName = cn(
'text-muted-foreground hover:text-foreground transition-colors',
link.disabled && 'pointer-events-none opacity-50',
className
)
if (link.external) {
return (
<a
href={link.href}
target='_blank'
rel='noopener noreferrer'
className={linkClassName}
aria-disabled={link.disabled}
>
{link.title}
</a>
)
}
return (
<Link to={link.href} className={linkClassName} disabled={link.disabled}>
{link.title}
</Link>
)
}
interface NavLinkListProps {
links: TopNavLink[]
className?: string
itemClassName?: string
}
/**
* Renders a list of navigation links
* Used in both desktop and mobile navigation
*/
export function NavLinkList({
links,
className,
itemClassName,
}: NavLinkListProps) {
return (
<>
{links.map((link, index) => (
<NavLinkItem
key={index}
link={link}
className={cn(className, itemClassName)}
/>
))}
</>
)
}
@@ -0,0 +1,70 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { cn } from '@/lib/utils'
export function Navbar({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
data-slot='navbar'
className={cn('flex items-center justify-between py-4', className)}
{...props}
/>
)
}
export function NavbarLeft({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='navbar-left'
className={cn('flex items-center justify-start gap-4', className)}
{...props}
/>
)
}
export function NavbarRight({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='navbar-right'
className={cn('flex items-center justify-end gap-4', className)}
{...props}
/>
)
}
export function NavbarCenter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='navbar-center'
className={cn('flex items-center justify-center gap-4', className)}
{...props}
/>
)
}
@@ -0,0 +1,41 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { createContext, useContext, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
const PageFooterContext = createContext<HTMLDivElement | null>(null)
type PageFooterProviderProps = {
container: HTMLDivElement | null
children: ReactNode
}
export function PageFooterProvider(props: PageFooterProviderProps) {
return (
<PageFooterContext.Provider value={props.container}>
{props.children}
</PageFooterContext.Provider>
)
}
export function PageFooterPortal(props: { children: ReactNode }) {
const container = useContext(PageFooterContext)
if (!container) return null
return createPortal(props.children, container)
}
@@ -0,0 +1,448 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { ThemeSwitch } from '@/components/theme-switch'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import { defaultTopNavLinks } from '../config/top-nav.config'
import type { TopNavLink } from '../types'
import { HeaderLogo } from './header-logo'
const AUTH_PROMPT_SECONDS = 5
type AuthPromptTarget = {
title: string
href: string
}
export interface PublicHeaderProps {
navLinks?: TopNavLink[]
mobileLinks?: TopNavLink[]
navContent?: React.ReactNode
showThemeSwitch?: boolean
showLanguageSwitcher?: boolean
logo?: React.ReactNode
siteName?: string
homeUrl?: string
leftContent?: React.ReactNode
rightContent?: React.ReactNode
showNavigation?: boolean
showAuthButtons?: boolean
showNotifications?: boolean
className?: string
}
export function PublicHeader(props: PublicHeaderProps) {
const {
navLinks = defaultTopNavLinks,
showThemeSwitch = true,
showLanguageSwitcher = true,
logo: customLogo,
siteName: customSiteName,
homeUrl = '/',
showAuthButtons = true,
showNotifications = true,
} = props
const { t } = useTranslation()
const navigate = useNavigate()
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [authPromptTarget, setAuthPromptTarget] =
useState<AuthPromptTarget | null>(null)
const [authPromptSecondsLeft, setAuthPromptSecondsLeft] =
useState(AUTH_PROMPT_SECONDS)
const { auth } = useAuthStore()
const {
systemName,
logo: systemLogo,
loading,
logoLoaded,
} = useSystemConfig()
const dynamicLinks = useTopNavLinks()
const notifications = useNotifications()
const routerState = useRouterState()
const pathname = routerState.location.pathname
const user = auth.user
const isAuthenticated = !!user
const displaySiteName = customSiteName || systemName
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
useEffect(() => {
document.body.style.overflow = mobileOpen ? 'hidden' : ''
return () => {
document.body.style.overflow = ''
}
}, [mobileOpen])
useEffect(() => {
if (!authPromptTarget) return
const intervalId = window.setInterval(() => {
setAuthPromptSecondsLeft((seconds) => Math.max(seconds - 1, 0))
}, 1000)
const timeoutId = window.setTimeout(() => {
const redirect = authPromptTarget.href
setAuthPromptTarget(null)
navigate({ to: '/sign-in', search: { redirect } })
}, AUTH_PROMPT_SECONDS * 1000)
return () => {
window.clearInterval(intervalId)
window.clearTimeout(timeoutId)
}
}, [authPromptTarget, navigate])
const closeAuthPrompt = useCallback(() => {
setAuthPromptTarget(null)
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
}, [])
const navigateToSignIn = useCallback(() => {
const redirect = authPromptTarget?.href || '/'
setAuthPromptTarget(null)
navigate({ to: '/sign-in', search: { redirect } })
}, [authPromptTarget?.href, navigate])
const handleNavLinkClick = useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
link: TopNavLink,
closeMobile = false
) => {
if (link.disabled) {
event.preventDefault()
return
}
if (link.requiresAuth) {
event.preventDefault()
if (closeMobile) {
setMobileOpen(false)
}
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
setAuthPromptTarget({
title: t(link.title),
href: link.href,
})
return
}
if (closeMobile) {
setMobileOpen(false)
}
},
[t]
)
return (
<>
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
<div
className={cn(
'pointer-events-auto mx-auto transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
scrolled ? 'max-w-[52rem] px-3 pt-3' : 'max-w-7xl px-4 pt-0 md:px-6'
)}
>
<nav
className={cn(
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
scrolled
? 'bg-background/60 ring-border/50 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.08),0_0_0_0.5px_rgba(0,0,0,0.02)] ring-[0.5px] backdrop-blur-2xl dark:shadow-[0_2px_16px_-6px_rgba(0,0,0,0.4)]'
: 'h-16 px-2'
)}
>
{/* Logo */}
<Link
to={homeUrl}
className='group flex shrink-0 items-center gap-2.5'
>
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
{loading ? (
<Skeleton className='size-full rounded-lg' />
) : customLogo ? (
customLogo
) : (
<HeaderLogo
src={systemLogo}
loading={loading}
logoLoaded={logoLoaded}
className='size-full rounded-lg object-contain'
/>
)}
</div>
<span className='text-sm font-semibold tracking-tight'>
{loading ? <Skeleton className='h-4 w-16' /> : displaySiteName}
</span>
</Link>
{/* Desktop nav */}
<div className='hidden items-center gap-0.5 sm:flex'>
{links.map((link, i) => {
const isActive = pathname === link.href
if (link.external) {
return (
<a
key={i}
href={link.href}
target='_blank'
rel='noopener noreferrer'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{t(link.title)}
</a>
)
}
return (
<Link
key={i}
to={link.href}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{t(link.title)}
</Link>
)
})}
{(showLanguageSwitcher ||
showThemeSwitch ||
showNotifications) && (
<div className='bg-border/40 mx-2 h-4 w-px' />
)}
{showLanguageSwitcher && <LanguageSwitcher />}
{showThemeSwitch && <ThemeSwitch />}
{showNotifications && (
<NotificationPopover
open={notifications.popoverOpen}
onOpenChange={notifications.setPopoverOpen}
unreadCount={notifications.unreadCount}
activeTab={notifications.activeTab}
onTabChange={notifications.setActiveTab}
notice={notifications.notice}
announcements={notifications.announcements}
loading={notifications.loading}
/>
)}
{showAuthButtons && (
<>
<div className='bg-border/40 mx-1 h-4 w-px' />
{loading ? (
<Skeleton className='h-8 w-20 rounded-lg' />
) : isAuthenticated ? (
<ProfileDropdown />
) : (
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
render={<Link to='/sign-in' />}
>
{t('Sign in')}
</Button>
)}
</>
)}
</div>
{/* Mobile: compact actions + hamburger */}
<div className='flex items-center gap-2 sm:hidden'>
{showThemeSwitch && <ThemeSwitch />}
{showAuthButtons && !loading && isAuthenticated && (
<ProfileDropdown />
)}
<Button
type='button'
variant='ghost'
size='icon'
className='size-9'
onClick={() => setMobileOpen((v) => !v)}
aria-label={t('Toggle navigation menu')}
>
<div className='relative size-4'>
<span
className={cn(
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
mobileOpen ? 'top-[7px] rotate-45' : 'top-[3px]'
)}
/>
<span
className={cn(
'absolute inset-x-0 top-[7px] block h-[1.5px] rounded-full bg-current transition-all duration-300',
mobileOpen ? 'scale-x-0 opacity-0' : 'opacity-100'
)}
/>
<span
className={cn(
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
mobileOpen ? 'top-[7px] -rotate-45' : 'top-[11px]'
)}
/>
</div>
</Button>
</div>
</nav>
</div>
</header>
{/* Mobile full-screen overlay */}
<div
className={cn(
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
mobileOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)}
>
<div className='flex h-full flex-col justify-between px-8 pt-20 pb-10'>
<nav className='flex flex-col gap-1'>
{links.map((link, i) => {
const isActive = pathname === link.href
const linkClassName = cn(
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0',
isActive ? 'text-foreground' : 'text-muted-foreground',
link.disabled && 'pointer-events-none opacity-50'
)
const transitionStyle = {
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
}
if (link.external) {
return (
<a
key={i}
href={link.href}
target='_blank'
rel='noopener noreferrer'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link, true)}
className={linkClassName}
style={transitionStyle}
>
{t(link.title)}
</a>
)
}
return (
<Link
key={i}
to={link.href}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link, true)}
className={linkClassName}
style={transitionStyle}
>
{t(link.title)}
</Link>
)
})}
</nav>
<div
className={cn(
'flex flex-col gap-3 transition-all duration-500',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0'
)}
style={{ transitionDelay: mobileOpen ? '250ms' : '0ms' }}
>
{showAuthButtons && (
<Link
to={isAuthenticated ? '/dashboard' : '/sign-in'}
onClick={() => setMobileOpen(false)}
className='bg-foreground text-background inline-flex h-10 items-center justify-center rounded-lg text-sm font-medium transition-opacity hover:opacity-90 active:opacity-80'
>
{isAuthenticated ? t('Go to Dashboard') : t('Sign in')}
</Link>
)}
</div>
</div>
</div>
<Dialog
open={!!authPromptTarget}
onOpenChange={(open) => {
if (!open) {
closeAuthPrompt()
}
}}
title={t('Sign in required')}
description={t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
contentClassName='sm:max-w-md'
contentHeight='auto'
footer={
<>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</>
}
>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
</Dialog>
</>
)
}
@@ -0,0 +1,58 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TopNavLink } from '../types'
import { PublicHeader, type PublicHeaderProps } from './public-header'
type PublicLayoutProps = {
children: React.ReactNode
showMainContainer?: boolean
navContent?: React.ReactNode
headerProps?: Omit<PublicHeaderProps, 'navContent'>
navLinks?: TopNavLink[]
showThemeSwitch?: boolean
showAuthButtons?: boolean
showNotifications?: boolean
logo?: React.ReactNode
siteName?: string
}
export function PublicLayout(props: PublicLayoutProps) {
return (
<div className='bg-background text-foreground relative min-h-svh overflow-x-clip'>
<PublicHeader
navContent={props.navContent}
navLinks={props.navLinks}
showThemeSwitch={props.showThemeSwitch}
showAuthButtons={props.showAuthButtons}
showNotifications={props.showNotifications}
logo={props.logo}
siteName={props.siteName}
{...props.headerProps}
/>
{props.showMainContainer !== false ? (
<main className='container px-4 py-6 pt-20 md:px-4'>
{props.children}
</main>
) : (
props.children
)}
</div>
)
}
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { cn } from '@/lib/utils'
import { defaultTopNavLinks } from '../config/top-nav.config'
import type { TopNavLink } from '../types'
interface PublicNavigationProps {
/**
* Custom navigation links
* If not provided, will use dynamic links from backend or defaults
*/
links?: TopNavLink[]
/**
* Additional className
*/
className?: string
}
/**
* Public navigation component that matches Launch UI template styling
* Used in PublicHeader for desktop navigation
*/
export function PublicNavigation({
links: providedLinks,
className,
}: PublicNavigationProps = {}) {
// Use the same logic as AppHeader: prioritize dynamic links from backend
const dynamicLinks = useTopNavLinks()
const defaultLinks = providedLinks || defaultTopNavLinks
const links = dynamicLinks.length > 0 ? dynamicLinks : defaultLinks
return (
<nav className={cn('hidden items-center gap-1 md:flex', className)}>
{links.map((link, index) => {
// Handle external links
if (link.external) {
return (
<a
key={index}
href={link.href}
target='_blank'
rel='noopener noreferrer'
className={cn(
'text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors focus:outline-none',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{link.title}
</a>
)
}
// Handle internal links
return (
<Link
key={index}
to={link.href}
className={cn(
'text-muted-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground inline-flex h-9 w-max items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium transition-colors focus:outline-none',
link.disabled && 'pointer-events-none opacity-50'
)}
>
{link.title}
</Link>
)
})}
</nav>
)
}
@@ -0,0 +1,122 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
Children,
isValidElement,
useState,
type ReactElement,
type ReactNode,
} from 'react'
import { Main } from './main'
import { PageFooterProvider } from './page-footer'
type SlotProps = { children?: ReactNode }
function SectionPageLayoutTitle(_props: SlotProps) {
return null
}
SectionPageLayoutTitle.displayName = 'SectionPageLayout.Title'
function SectionPageLayoutActions(_props: SlotProps) {
return null
}
SectionPageLayoutActions.displayName = 'SectionPageLayout.Actions'
function SectionPageLayoutContent(_props: SlotProps) {
return null
}
SectionPageLayoutContent.displayName = 'SectionPageLayout.Content'
function SectionPageLayoutBreadcrumb(_props: SlotProps) {
return null
}
SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
export type SectionPageLayoutProps = {
children: ReactNode
fixedContent?: boolean
}
export function SectionPageLayout(props: SectionPageLayoutProps) {
const [footerContainer, setFooterContainer] = useState<HTMLDivElement | null>(
null
)
let title: ReactNode = null
let actions: ReactNode = null
let content: ReactNode = null
let breadcrumb: ReactNode = null
Children.forEach(props.children, (node) => {
if (!isValidElement(node)) return
const child = node as ReactElement<SlotProps>
if (child.type === SectionPageLayoutTitle) title = child.props.children
else if (child.type === SectionPageLayoutActions)
actions = child.props.children
else if (child.type === SectionPageLayoutContent)
content = child.props.children
else if (child.type === SectionPageLayoutBreadcrumb)
breadcrumb = child.props.children
})
return (
<PageFooterProvider container={footerContainer}>
<Main>
<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 flex-1'>
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
{title}
</h2>
</div>
{actions != null && (
<div className='flex shrink-0 flex-wrap items-center justify-end gap-2 sm:gap-x-4'>
{actions}
</div>
)}
</div>
</div>
<div
className={
props.fixedContent
? 'min-h-0 flex-1 overflow-hidden px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
: '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>
<div
ref={setFooterContainer}
className='bg-background shrink-0 border-t px-3 py-2.5 empty:hidden sm:px-4 sm:py-3'
/>
</Main>
</PageFooterProvider>
)
}
SectionPageLayout.Title = SectionPageLayoutTitle
SectionPageLayout.Actions = SectionPageLayoutActions
SectionPageLayout.Content = SectionPageLayoutContent
SectionPageLayout.Breadcrumb = SectionPageLayoutBreadcrumb
@@ -0,0 +1,37 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { cn } from '@/lib/utils'
export function Section({
className,
...props
}: React.ComponentProps<'section'>) {
return (
<section
data-slot='section'
className={cn(
'bg-background text-foreground px-4 py-6 sm:py-12 md:py-20',
className
)}
{...props}
/>
)
}
@@ -0,0 +1,72 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { ChevronLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { cn } from '@/lib/utils'
import type { SidebarView } from '../types'
type SidebarViewHeaderProps = {
view: SidebarView
}
/**
* Header for a nested sidebar view (Vercel / Cloudflare drill-in pattern).
*
* Renders only the back affordance — workspace context is conveyed by
* the nav groups below, not a redundant title row.
*/
export function SidebarViewHeader(props: SidebarViewHeaderProps) {
const { t } = useTranslation()
const { setOpenMobile } = useSidebar()
return (
<SidebarHeader className='border-sidebar-border border-b px-2 py-2'>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip={t(props.view.parent.label)}
className={cn(
'text-muted-foreground hover:text-foreground',
'gap-1.5 font-medium'
)}
render={
<Link
to={props.view.parent.to}
onClick={() => setOpenMobile(false)}
/>
}
>
<ChevronLeft className='size-4 shrink-0' />
<span className='truncate'>{t(props.view.parent.label)}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
)
}
@@ -0,0 +1,103 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import { cn } from '@/lib/utils'
type SystemBrandProps = {
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'
}
/**
* System brand component
* Displays current system logo + name.
* - inline: compact pill in the top app bar; clicking navigates to home (/)
* - sidebar: stacked card in the sidebar header (display only)
*/
export function SystemBrand(props: SystemBrandProps) {
const { t } = useTranslation()
const { status } = useStatus()
const { logo } = useSystemConfig()
const variant = props.variant ?? 'sidebar'
const name = status?.system_name || props.defaultName || 'New API'
const version =
status?.version || props.defaultVersion || t('Unknown version')
if (variant === 'inline') {
return (
<Link
to='/'
aria-label={t('Go to home')}
className={cn(
'text-foreground inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
'hover:bg-accent focus-visible:ring-ring/40 focus-visible:ring-2'
)}
>
<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>
<span className='max-w-[12rem] truncate'>{name}</span>
</Link>
)
}
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size='lg'
className='hover:text-sidebar-foreground active:text-sidebar-foreground cursor-default hover:bg-transparent active:bg-transparent'
render={<div />}
>
<div className='flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg'>
<img
src={logo}
alt={t('Logo')}
className='size-full rounded-lg object-cover'
/>
</div>
<div className='grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden'>
<span className='truncate font-semibold'>{name}</span>
<span className='truncate text-xs'>{version}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,130 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link } from '@tanstack/react-router'
import { Menu } from 'lucide-react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { type TopNavLink } from '../types'
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
links: TopNavLink[]
}
/**
* 顶部导航栏组件
* 在大屏幕显示水平导航,在小屏幕显示下拉菜单
*/
export function TopNav({ className, links, ...props }: TopNavProps) {
// 规范化链接,确保所有可选属性都有默认值
const normalizedLinks = useMemo(
() =>
links.map((link) => ({
isActive: false,
disabled: false,
external: false,
...link,
})),
[links]
)
return (
<>
{/* 移动端下拉菜单 */}
<div className='lg:hidden'>
<DropdownMenu modal={false}>
<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}`}
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>
</DropdownMenu>
</div>
{/* 桌面端水平导航 */}
<nav
className={cn(
'hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6',
className
)}
{...props}
>
{normalizedLinks.map(({ title, href, isActive, disabled, external }) =>
external ? (
<a
key={`${title}-${href}`}
href={href}
target='_blank'
rel='noopener noreferrer'
className={`hover:text-primary text-sm font-medium transition-colors ${isActive ? '' : 'text-muted-foreground'}`}
>
{title}
</a>
) : (
<Link
key={`${title}-${href}`}
to={href}
disabled={disabled}
className={`hover:text-primary text-sm font-medium transition-colors ${isActive ? '' : 'text-muted-foreground'}`}
>
{title}
</Link>
)
)}
</nav>
</>
)
}
@@ -0,0 +1,108 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import {
Box,
CreditCard,
Layout,
Settings,
Shield,
ShieldAlert,
Wrench,
} from 'lucide-react'
import { getAuthSectionNavItems } from '@/features/system-settings/auth/section-registry.tsx'
import { getBillingSectionNavItems } from '@/features/system-settings/billing/section-registry.tsx'
import { getContentSectionNavItems } from '@/features/system-settings/content/section-registry.tsx'
import { getModelsSectionNavItems } from '@/features/system-settings/models/section-registry.tsx'
import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx'
import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx'
import { getSiteSectionNavItems } from '@/features/system-settings/site/section-registry.tsx'
import type { NavGroup, SidebarView } from '../types'
/**
* Sidebar nav groups for the System Settings nested view.
*
* Kept as a single group because the workspace title in the sidebar
* header already provides top-level context — the inner group label
* scopes the items as "administration" actions.
*/
function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
return [
{
id: 'system-administration',
title: t('System Administration'),
items: [
{
title: t('Site & Branding'),
icon: Settings,
items: getSiteSectionNavItems(t),
},
{
title: t('Authentication'),
icon: Shield,
items: getAuthSectionNavItems(t),
},
{
title: t('Billing & Payment'),
icon: CreditCard,
items: getBillingSectionNavItems(t),
},
{
title: t('Models & Routing'),
icon: Box,
items: getModelsSectionNavItems(t),
},
{
title: t('Security & Limits'),
icon: ShieldAlert,
items: getSecuritySectionNavItems(t),
},
{
title: t('Console Content'),
icon: Layout,
items: getContentSectionNavItems(t),
},
{
title: t('Operations'),
icon: Wrench,
items: getOperationsSectionNavItems(t),
},
],
},
]
}
/**
* Nested sidebar view for `/system-settings/*`.
*
* Activates the Vercel / Cloudflare-style drill-in sidebar:
* the root navigation is replaced by the system administration
* groups, with a "Back to Dashboard" affordance in the header.
*/
export const SYSTEM_SETTINGS_VIEW: SidebarView = {
id: 'system-settings',
pathPattern: /^\/system-settings(\/|$)/,
parent: {
to: '/dashboard/overview',
label: 'Back to Dashboard',
},
getNavGroups: getSystemSettingsNavGroups,
}
@@ -0,0 +1,30 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TopNavLink } from '../types'
/**
* Default top navigation links
*
* In practice, navigation links are dynamically fetched from backend.
* Priority: Backend dynamic links > Provided navLinks > defaultTopNavLinks
*
* This is intentionally empty to encourage backend configuration.
* If you need fallback links, add them here.
*/
export const defaultTopNavLinks: TopNavLink[] = []
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* Layout constants and configurations
*/
/**
* Animation variants for mobile drawer
*/
export const MOBILE_DRAWER_ANIMATION = {
overlay: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
},
drawer: {
hidden: { opacity: 0, y: 100 },
visible: {
opacity: 1,
y: 0,
rotate: 0,
transition: {
type: 'spring',
damping: 15,
stiffness: 200,
staggerChildren: 0.03,
},
},
exit: {
opacity: 0,
y: 100,
transition: { duration: 0.1 },
},
},
menuItem: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
} as const
/**
* Mobile drawer configuration
*/
export const MOBILE_DRAWER_CONFIG = {
overlayTransitionDuration: 0.2,
drawerClassName:
'fixed inset-x-0 bottom-3 z-50 mx-auto w-[95%] rounded-xl border border-border bg-background p-4 shadow-lg md:hidden',
overlayClassName: 'fixed inset-0 z-40 bg-black/50 backdrop-blur-sm',
} as const
+67
View File
@@ -0,0 +1,67 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* Public surface of the Layout module.
*/
// Core components
export { AppHeader } from './components/app-header'
export { AppSidebar } from './components/app-sidebar'
export { AuthenticatedLayout } from './components/authenticated-layout'
export { PublicLayout } from './components/public-layout'
export { PublicHeader } from './components/public-header'
export { PublicNavigation } from './components/public-navigation'
export { HeaderLogo } from './components/header-logo'
export { NavLinkItem, NavLinkList } from './components/nav-link-item'
export { Header } from './components/header'
export { Main } from './components/main'
export { PageFooterPortal } from './components/page-footer'
export { NavGroup } from './components/nav-group'
export { SectionPageLayout } from './components/section-page-layout'
export { SidebarViewHeader } from './components/sidebar-view-header'
export { SystemBrand } from './components/system-brand'
export { TopNav } from './components/top-nav'
export { MobileDrawer } from './components/mobile-drawer'
// Configuration
export { SYSTEM_SETTINGS_VIEW } from './config/system-settings.config'
export { defaultTopNavLinks } from './config/top-nav.config'
// Constants
export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants'
// Sidebar view registry
export {
getNavGroupsForPath,
resolveSidebarView,
} from './lib/sidebar-view-registry'
// Type exports (type-only to avoid conflicts with components above)
export type {
NavCollapsible,
NavGroup as NavGroupType,
NavItem,
NavLink,
ResolvedSidebarView,
SidebarData,
SidebarView,
SidebarViewParent,
TopNavLink,
} from './types'
export type { SectionPageLayoutProps } from './components/section-page-layout'
@@ -0,0 +1,59 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import { SYSTEM_SETTINGS_VIEW } from '../config/system-settings.config'
import type { NavGroup, SidebarView } from '../types'
/**
* Registered nested sidebar views.
*
* Each entry describes a contextual sidebar that replaces the root
* navigation when the user enters that workspace (Vercel-style
* "drill-in" pattern). Add new entries here to register a new view.
*
* Match priority is array order; the first matching `pathPattern` wins.
*/
const SIDEBAR_VIEWS: readonly SidebarView[] = [SYSTEM_SETTINGS_VIEW]
/**
* Resolve the active nested view for the given path.
*
* @returns Matching {@link SidebarView}, or `null` when the root
* navigation should be displayed.
*/
export function resolveSidebarView(pathname: string): SidebarView | null {
return SIDEBAR_VIEWS.find((view) => view.pathPattern.test(pathname)) ?? null
}
/**
* Backwards-compatible helper for consumers (e.g. command palette) that
* just need the navigation groups for the current path, without caring
* about the view metadata.
*
* @returns Nav groups for the matched view, or `null` if no nested view
* matches (callers should then fall back to root nav groups).
*/
export function getNavGroupsForPath(
pathname: string,
t: TFunction
): NavGroup[] | null {
const view = resolveSidebarView(pathname)
return view ? view.getNavGroups(t) : null
}
+122
View File
@@ -0,0 +1,122 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { LinkProps } from '@tanstack/react-router'
import type { NavItem, NavCollapsible } from '../types'
/**
* Convert LinkProps['to'] to string
* Handles both string URLs and object URLs (e.g., { pathname, search })
*/
function urlToString(url: LinkProps['to'] | (string & {})): string | null {
if (typeof url === 'string') {
return url
}
if (url && typeof url === 'object' && !Array.isArray(url)) {
// Handle object URLs like { pathname: string, search?: string }
const urlObj = url as Record<string, unknown>
const pathname = typeof urlObj.pathname === 'string' ? urlObj.pathname : ''
const search = typeof urlObj.search === 'string' ? urlObj.search : ''
return pathname + search
}
return null
}
/**
* Normalize URL by removing query parameters and trailing slashes
*/
export function normalizeHref(href: string): string {
const withoutQuery = href.split('?')[0]
return withoutQuery.length > 1
? withoutQuery.replace(/\/+$/, '')
: withoutQuery
}
/**
* Check if a navigation item is active
* @param href - Current URL
* @param item - Navigation item
* @param mainNav - Whether this is a main navigation item (matches first-level path)
*/
export function checkIsActive(
href: string,
item: NavItem,
mainNav = false
): boolean {
const hrefWithoutQuery = href.split('?')[0]
if (item.activeUrls?.some((url) => urlToString(url) === hrefWithoutQuery)) {
return true
}
// For collapsible items (NavCollapsible), check sub-items first
if ('items' in item && item.items) {
const collapsibleItem = item as NavCollapsible
const items = collapsibleItem.items
// Check if any sub-item matches
if (
items.some((i) => {
if (!i?.url) return false
const subItemUrl = urlToString(i.url)
if (!subItemUrl) return false
if (href === subItemUrl) return true
const subItemUrlWithoutQuery = subItemUrl.split('?')[0]
const subItemUrlHasQuery = subItemUrl.includes('?')
if (subItemUrlWithoutQuery === hrefWithoutQuery) {
// If sub-item URL has no query params, pathname match is enough (href may have query params)
if (!subItemUrlHasQuery) return true
// If sub-item URL has query params, they must match exactly
if (subItemUrlHasQuery && href === subItemUrl) return true
}
return false
})
)
return true
}
// For regular link items, check the item's URL
if (!item.url) return false
const itemUrl = urlToString(item.url)
if (!itemUrl) return false
// Exact match
if (href === itemUrl) return true
const itemUrlWithoutQuery = itemUrl.split('?')[0]
const itemUrlHasQuery = itemUrl.includes('?')
// If both URLs have the same base path
if (hrefWithoutQuery === itemUrlWithoutQuery) {
// If item.url has no query params, pathname match is enough (current URL may have query params)
if (!itemUrlHasQuery) return true
// If item.url has query params, they must match exactly
if (itemUrlHasQuery && href === itemUrl) return true
}
// Main navigation match (matches first-level path)
if (mainNav && href.split('/')[1] && itemUrl) {
const hrefFirstPath = href.split('/')[1]
const itemFirstPath = itemUrl.split('/')[1]
return hrefFirstPath === itemFirstPath
}
return false
}
+142
View File
@@ -0,0 +1,142 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type LinkProps } from '@tanstack/react-router'
import { type TFunction } from 'i18next'
/**
* Base navigation item type
*/
type BaseNavItem = {
title: string
badge?: string
icon?: React.ElementType
activeUrls?: (LinkProps['to'] | (string & {}))[]
configUrls?: (LinkProps['to'] | (string & {}))[]
/**
* Minimum role required to see this item in the sidebar. When set, the item
* is hidden for users whose role is below this threshold (see
* `useSidebarView`). Route-level guards still enforce access independently.
*/
requiredRole?: number
}
/**
* Navigation link type - single link item
*/
export type NavLink = BaseNavItem & {
url: LinkProps['to'] | (string & {})
items?: never
type?: never
}
/**
* Navigation collapsible type - collapsible navigation with sub-items
*/
export type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
url?: never
type?: never
}
/**
* Dynamic chat presets type - dynamically loaded chat preset list from API
*/
export type NavChatPresets = BaseNavItem & {
type: 'chat-presets'
url?: never
items?: never
}
/**
* Navigation item union type
*/
export type NavItem = NavCollapsible | NavLink | NavChatPresets
/**
* Navigation group type - a group of navigation items in sidebar
*/
export type NavGroup = {
id?: string
title: string
items: NavItem[]
}
/**
* Root sidebar data type
*
* Used by the default (top-level) sidebar view that lists primary
* application navigation (chat, dashboard, admin, etc).
*/
export type SidebarData = {
navGroups: NavGroup[]
}
/**
* Top navigation link type
*/
export type TopNavLink = {
title: string
href: string
isActive?: boolean
disabled?: boolean
requiresAuth?: boolean
external?: boolean
}
/**
* Back-navigation descriptor for a nested sidebar view
*/
export type SidebarViewParent = {
/** Destination URL for the back button */
to: LinkProps['to'] | (string & {})
/** Visible label, e.g. "Back to Dashboard" — already localized */
label: string
}
/**
* Nested sidebar view configuration
*
* A nested view replaces the root navigation when the user enters a
* dedicated workspace (e.g. System Settings). It models the modern
* Vercel / Cloudflare "drill-in" sidebar UX: clicking a top-level entry
* swaps the sidebar to a contextual view with a "Back" affordance.
*/
export type SidebarView = {
/** Stable identifier (also drives transition animation keys) */
id: string
/** Path matcher that activates this view */
pathPattern: RegExp
/** Back-navigation descriptor; required for nested views */
parent: SidebarViewParent
/** Nav group builder, called per render with the active translator */
getNavGroups: (t: TFunction) => NavGroup[]
}
/**
* Resolved sidebar view returned by `useSidebarView()`
*
* - `view === null`: root navigation (default sidebar)
* - `view !== null`: nested workspace view (renders header + back button)
*/
export type ResolvedSidebarView = {
/** Animation/identity key — falls back to a sentinel for the root view */
key: string
view: SidebarView | null
navGroups: NavGroup[]
}