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:
@@ -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'>
|
||||
© {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>
|
||||
© {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[] = []
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user