mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +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