diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 1f7ac0c..99ee2b7 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { PageTransition } from '@/components/common/PageTransition'; import { MainRoutes } from '@/router/MainRoutes'; +import { pluginsApi } from '@/services/api'; import { IconSidebarAuthFiles, IconSidebarConfig, @@ -22,6 +23,7 @@ import { IconSidebarProviders, IconSidebarQuota, IconSidebarSystem, + IconChevronDown, } from '@/components/ui/icons'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { @@ -31,6 +33,10 @@ import { useNotificationStore, useThemeStore, } from '@/stores'; +import { + collectPluginResourceEntries, + type PluginResourceEntry, +} from '@/features/plugins/pluginResources'; import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; import { isSupportedLanguage } from '@/utils/language'; @@ -48,6 +54,36 @@ const sidebarIcons: Record = { system: , }; +interface SidebarNavLinkItem { + kind?: 'link'; + path: string; + labelKey?: string; + metaKey?: string; + label?: string; + meta?: string; + icon: ReactNode; +} + +interface SidebarNavDrawerItem { + kind: 'drawer'; + id: string; + label: string; + meta?: string; + icon: ReactNode; + children: SidebarNavLinkItem[]; +} + +type SidebarNavItem = SidebarNavLinkItem | SidebarNavDrawerItem; + +interface SidebarNavGroup { + id: string; + labelKey: string; + items: SidebarNavItem[]; +} + +const flattenNavItems = (items: SidebarNavItem[]): SidebarNavLinkItem[] => + items.flatMap((item) => item.kind === 'drawer' ? item.children : [item]); + // Header action icons - smaller size for header buttons const headerIconProps: SVGProps = { width: 16, @@ -214,6 +250,8 @@ export function MainLayout() { const location = useLocation(); const logout = useAuthStore((state) => state.logout); + const connectionStatus = useAuthStore((state) => state.connectionStatus); + const apiBase = useAuthStore((state) => state.apiBase); const fetchConfig = useConfigStore((state) => state.fetchConfig); const clearCache = useConfigStore((state) => state.clearCache); @@ -227,6 +265,10 @@ export function MainLayout() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [themeMenuOpen, setThemeMenuOpen] = useState(false); + const [pluginResources, setPluginResources] = useState([]); + const [expandedPluginResourceIDs, setExpandedPluginResourceIDs] = useState>( + () => new Set() + ); const contentRef = useRef(null); const languageMenuRef = useRef(null); const themeMenuRef = useRef(null); @@ -235,6 +277,7 @@ export function MainLayout() { const fullBrandName = 'CLI Proxy API Management Center'; const abbrBrandName = t('title.abbr'); const isLogsPage = location.pathname.startsWith('/logs'); + const isPluginResourcePage = location.pathname.startsWith('/plugin-pages'); const showSidebarLabels = !sidebarCollapsed || sidebarOpen; // Keep floating header height available to sticky mobile elements and overlays. @@ -385,7 +428,78 @@ export function MainLayout() { }); }, [fetchConfig]); - const navGroups = [ + const loadPluginResources = useCallback(async () => { + if (connectionStatus !== 'connected') { + setPluginResources([]); + return; + } + + try { + const plugins = await pluginsApi.list(); + setPluginResources(collectPluginResourceEntries(plugins.plugins)); + } catch { + setPluginResources([]); + } + }, [connectionStatus]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadPluginResources(); + }, 0); + + return () => window.clearTimeout(timer); + }, [apiBase, loadPluginResources]); + + const pluginResourceGroups = pluginResources.reduce< + Array<{ pluginID: string; pluginTitle: string; entries: PluginResourceEntry[] }> + >((groups, resource) => { + const group = groups.find((item) => item.pluginID === resource.pluginID); + if (group) { + group.entries.push(resource); + return groups; + } + + groups.push({ + pluginID: resource.pluginID, + pluginTitle: resource.pluginTitle, + entries: [resource], + }); + return groups; + }, []); + + const pluginPageNavItems: SidebarNavItem[] = pluginResourceGroups.flatMap( + (group): SidebarNavItem[] => { + if (group.entries.length === 1) { + const resource = group.entries[0]; + return [ + { + path: resource.route, + label: resource.label, + meta: resource.description, + icon: sidebarIcons.plugins, + }, + ]; + } + + return [ + { + kind: 'drawer', + id: `plugin-pages-${group.pluginID}`, + label: group.pluginTitle, + meta: t('plugin_resource.page_count', { count: group.entries.length }), + icon: sidebarIcons.plugins, + children: group.entries.map((resource) => ({ + path: resource.route, + label: resource.label, + meta: resource.description, + icon: