diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx new file mode 100644 index 00000000..b00da9f4 --- /dev/null +++ b/ui/src/components/layout/app-sidebar.tsx @@ -0,0 +1,167 @@ +import { Link, useLocation } from 'react-router-dom'; +import { + Home, + Key, + Zap, + Users, + Settings, + Activity, + FolderOpen, + ChevronRight, + BarChart3, + Gauge, + Github, +} from 'lucide-react'; +import { + Sidebar, + SidebarContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarFooter, + SidebarTrigger, + SidebarGroupContent, +} from '@/components/ui/sidebar'; +import { CcsLogo } from '@/components/shared/ccs-logo'; +import { useSidebar } from '@/hooks/use-sidebar'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + +// Define navigation groups +const navGroups = [ + { + title: 'General', + items: [ + { path: '/', icon: Home, label: 'Home' }, + { path: '/analytics', icon: BarChart3, label: 'Analytics' }, + ], + }, + { + title: 'Identity & Access', + items: [ + { path: '/api', icon: Key, label: 'API Profiles' }, + { + path: '/cliproxy', + icon: Zap, + label: 'CLIProxy', + isCollapsible: true, + children: [ + { path: '/cliproxy', label: 'Overview' }, + { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, + ], + }, + { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, + { + path: '/accounts', + icon: Users, + label: 'Accounts', + isCollapsible: true, + children: [ + { path: '/accounts', label: 'All Accounts' }, + { path: '/shared', icon: FolderOpen, label: 'Shared Data' }, + ], + }, + ], + }, + { + title: 'System', + items: [ + { path: '/health', icon: Activity, label: 'Health' }, + { path: '/settings', icon: Settings, label: 'Settings' }, + ], + }, +]; + +export function AppSidebar() { + const location = useLocation(); + const { state } = useSidebar(); + + // Helper to check if a route is active (exact match) + const isRouteActive = (path: string) => location.pathname === path; + + // Helper to check if a group/parent should be open based on active child + // Also handles sub-routes (e.g., /cliproxy/control-panel matches /cliproxy) + const isParentActive = (children: { path: string }[]) => { + return children.some( + (child) => isRouteActive(child.path) || location.pathname.startsWith(child.path + '/') + ); + }; + + return ( + + + + + + + {navGroups.map((group, index) => ( + + {group.title && {group.title}} + + + {group.items.map((item) => ( + + {item.isCollapsible && item.children ? ( + + + + + {item.icon && } + + {item.label} + + + + + + + {item.children.map((child) => ( + + + + {child.label} + + + + ))} + + + + + ) : ( + + + {item.icon && } + {item.label} + + + )} + + ))} + + + + ))} + + + + + + + ); +} diff --git a/ui/src/components/layout/hero-section.tsx b/ui/src/components/layout/hero-section.tsx new file mode 100644 index 00000000..409900ff --- /dev/null +++ b/ui/src/components/layout/hero-section.tsx @@ -0,0 +1,23 @@ +import { Badge } from '@/components/ui/badge'; +import { CcsLogo } from '@/components/shared/ccs-logo'; + +interface HeroSectionProps { + version?: string; +} + +export function HeroSection({ version = '5.0.0' }: HeroSectionProps) { + return ( +
+ +
+
+

CCS Config

+ + v{version} + +
+

Claude Code Switch Dashboard

+
+
+ ); +} diff --git a/ui/src/components/layout/hub-footer.tsx b/ui/src/components/layout/hub-footer.tsx new file mode 100644 index 00000000..a81539f8 --- /dev/null +++ b/ui/src/components/layout/hub-footer.tsx @@ -0,0 +1,71 @@ +import { Separator } from '@/components/ui/separator'; +import { Button } from '@/components/ui/button'; +import { FileTextIcon, SettingsIcon, GithubIcon, ExternalLinkIcon } from 'lucide-react'; + +export function HubFooter() { + const currentYear = new Date().getFullYear(); + + const footerLinks = [ + { + icon: , + label: 'Logs', + href: '#logs', + onClick: () => console.log('Navigate to Logs'), + }, + { + icon: , + label: 'Settings', + href: '#settings', + onClick: () => console.log('Navigate to Settings'), + }, + { + icon: , + label: 'GitHub', + href: 'https://github.com/kaitranntt/ccs', + external: true, + }, + ]; + + return ( +
+
+
+ CCS v0.0.0 + + © {currentYear} kaitranntt +
+ +
+ {footerLinks.map((link) => ( + + ))} +
+
+
+ ); +} diff --git a/ui/src/components/layout/index.ts b/ui/src/components/layout/index.ts new file mode 100644 index 00000000..d5ad15e9 --- /dev/null +++ b/ui/src/components/layout/index.ts @@ -0,0 +1,10 @@ +/** + * Layout Components Barrel Export + */ + +export { AppSidebar } from './app-sidebar'; +export { HeroSection } from './hero-section'; +export { HubFooter } from './hub-footer'; +export { Layout } from './layout'; +export { ThemeProvider } from './theme-provider'; +export { ThemeToggle } from './theme-toggle'; diff --git a/ui/src/components/layout/layout.tsx b/ui/src/components/layout/layout.tsx new file mode 100644 index 00000000..d04ec791 --- /dev/null +++ b/ui/src/components/layout/layout.tsx @@ -0,0 +1,69 @@ +import { Suspense } from 'react'; +import { Outlet } from 'react-router-dom'; +import { SidebarProvider } from '@/components/ui/sidebar'; +import { AppSidebar } from './app-sidebar'; +import { ThemeToggle } from './theme-toggle'; +import { PrivacyToggle } from '@/components/shared/privacy-toggle'; +import { GitHubLink } from '@/components/shared/github-link'; +import { DocsLink } from '@/components/shared/docs-link'; +import { ConnectionIndicator } from '@/components/shared/connection-indicator'; +import { LocalhostDisclaimer } from '@/components/shared/localhost-disclaimer'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ClaudeKitBadge } from '@/components/shared/claudekit-badge'; +import { SponsorButton } from '@/components/shared/sponsor-button'; +import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog'; +import { useProjectSelection } from '@/hooks/use-project-selection'; + +function PageLoader() { + return ( +
+ + +
+ ); +} + +export function Layout() { + const { isOpen, prompt, onSelect, onClose } = useProjectSelection(); + + return ( + + +
+
+
+ + +
+
+ + + + + +
+
+
+ }> + + +
+ +
+ + {/* Global project selection dialog for OAuth flows */} + {prompt && ( + + )} +
+ ); +} diff --git a/ui/src/components/layout/theme-provider.tsx b/ui/src/components/layout/theme-provider.tsx new file mode 100644 index 00000000..d113fe46 --- /dev/null +++ b/ui/src/components/layout/theme-provider.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { ThemeProviderContext } from '@/contexts/theme-context'; +import type { Theme } from '@/contexts/theme-context'; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'vite-ui-theme', +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ); + + // Track system preference separately + // Initialize with window.matchMedia value if available + const [systemIsDark, setSystemIsDark] = useState(() => { + if (typeof window !== 'undefined') { + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + return false; + }); + + // Listen for system theme changes + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + const listener = (e: MediaQueryListEvent) => { + setSystemIsDark(e.matches); + }; + + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, []); + + // Derive effective theme + const isDark = theme === 'dark' || (theme === 'system' && systemIsDark); + + // Update DOM when effective theme changes + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(isDark ? 'dark' : 'light'); + }, [isDark]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + isDark, + }; + + return {children}; +} diff --git a/ui/src/components/layout/theme-toggle.tsx b/ui/src/components/layout/theme-toggle.tsx new file mode 100644 index 00000000..619b3a11 --- /dev/null +++ b/ui/src/components/layout/theme-toggle.tsx @@ -0,0 +1,19 @@ +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTheme } from '@/hooks/use-theme'; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + ); +}