mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user