refactor(ui): organize layout components into layout/ directory

- move app-sidebar, hero-section, hub-footer, layout

- move theme-provider, theme-toggle

- add barrel export in layout/index.ts
This commit is contained in:
kaitranntt
2025-12-19 19:42:54 -05:00
parent a106aa2ee6
commit bef9955123
7 changed files with 420 additions and 0 deletions
+167
View File
@@ -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 (
<Sidebar collapsible="icon">
<SidebarHeader className="h-12 flex items-center justify-center">
<CcsLogo size="sm" showText={state === 'expanded'} />
</SidebarHeader>
<SidebarContent>
{navGroups.map((group, index) => (
<SidebarGroup key={group.title || index}>
{group.title && <SidebarGroupLabel>{group.title}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.path}>
{item.isCollapsible && item.children ? (
<Collapsible
defaultOpen={isParentActive(item.children) || isRouteActive(item.path)}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.label}>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="group-data-[collapsible=icon]:hidden">
{item.label}
</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.children.map((child) => (
<SidebarMenuSubItem key={child.path}>
<SidebarMenuSubButton
asChild
isActive={isRouteActive(child.path)}
>
<Link to={child.path}>
<span>{child.label}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuButton
asChild
isActive={isRouteActive(item.path)}
tooltip={item.label}
>
<Link to={item.path}>
{item.icon && <item.icon className="w-4 h-4" />}
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter className="p-4 border-t flex items-center justify-center">
<SidebarTrigger />
</SidebarFooter>
</Sidebar>
);
}
+23
View File
@@ -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 (
<div className="flex items-center gap-4">
<CcsLogo size="lg" showText={false} />
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">CCS Config</h1>
<Badge variant="outline" className="font-mono text-xs">
v{version}
</Badge>
</div>
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
</div>
</div>
);
}
+71
View File
@@ -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: <FileTextIcon className="w-4 h-4" />,
label: 'Logs',
href: '#logs',
onClick: () => console.log('Navigate to Logs'),
},
{
icon: <SettingsIcon className="w-4 h-4" />,
label: 'Settings',
href: '#settings',
onClick: () => console.log('Navigate to Settings'),
},
{
icon: <GithubIcon className="w-4 h-4" />,
label: 'GitHub',
href: 'https://github.com/kaitranntt/ccs',
external: true,
},
];
return (
<footer className="mt-auto border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<span>CCS v0.0.0</span>
<Separator orientation="vertical" className="h-4" />
<span>© {currentYear} kaitranntt</span>
</div>
<div className="ml-auto flex items-center space-x-2">
{footerLinks.map((link) => (
<Button
key={link.label}
variant="ghost"
size="sm"
asChild={link.external}
onClick={link.onClick}
className="h-8 px-2"
>
{link.external ? (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
{link.icon}
<span className="hidden sm:inline">{link.label}</span>
<ExternalLinkIcon className="w-3 h-3" />
</a>
) : (
<div className="flex items-center gap-1">
{link.icon}
<span className="hidden sm:inline">{link.label}</span>
</div>
)}
</Button>
))}
</div>
</div>
</footer>
);
}
+10
View File
@@ -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';
+69
View File
@@ -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 (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export function Layout() {
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
return (
<SidebarProvider>
<AppSidebar />
<main className="flex-1 flex flex-col min-h-0 overflow-hidden bg-background">
<header className="flex h-14 items-center justify-between px-6 border-b shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-3">
<ClaudeKitBadge />
<SponsorButton />
</div>
<div className="flex items-center gap-2">
<ConnectionIndicator />
<DocsLink />
<GitHubLink />
<PrivacyToggle />
<ThemeToggle />
</div>
</header>
<div className="flex-1 overflow-auto min-h-0">
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</div>
<LocalhostDisclaimer />
</main>
{/* Global project selection dialog for OAuth flows */}
{prompt && (
<ProjectSelectionDialog
open={isOpen}
onClose={onClose}
sessionId={prompt.sessionId}
provider={prompt.provider}
projects={prompt.projects}
defaultProjectId={prompt.defaultProjectId}
supportsAll={prompt.supportsAll}
onSelect={onSelect}
/>
)}
</SidebarProvider>
);
}
@@ -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<Theme>(
() => (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 <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>;
}
+19
View File
@@ -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 (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}