Merge pull request #94 from kaitranntt/kai/feat/improve-config-home

feat(ui): refactor home page and remove unused progress component
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-13 02:55:33 -05:00
commit 8ed5babca1
13 files changed
+666 -86

No files matched your search

+7 -4
View File
@@ -4,8 +4,8 @@
![CCS Logo](assets/ccs-logo-medium.png)
### Manage multiple AI accounts from one dashboard.
Run Claude, Gemini, GLM, and more - concurrently, without conflicts.
### The universal AI profile manager for Claude Code.
Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, without conflicts.
[![License](https://img.shields.io/badge/license-MIT-C15F3C?style=for-the-badge)](LICENSE)
[![npm](https://img.shields.io/npm/v/@kaitranntt/ccs?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@kaitranntt/ccs)
@@ -23,7 +23,7 @@ Run Claude, Gemini, GLM, and more - concurrently, without conflicts.
|------------|--------------|------------|
| **Multiple Claude Accounts** | Run work + personal Claude subs simultaneously | Dashboard |
| **OAuth Providers** | Gemini, Codex, Antigravity - zero API keys needed | Dashboard |
| **API Profiles** | GLM, Kimi with your own API keys | Dashboard |
| **API Profiles** | GLM, Kimi, or any Anthropic-compatible API | Dashboard |
<br>
@@ -76,7 +76,7 @@ The dashboard provides visual management for all account types:
<br>
## Supported Providers
## Built-in Providers
| Provider | Auth Type | Command | Best For |
|----------|-----------|---------|----------|
@@ -89,6 +89,9 @@ The dashboard provides visual management for all account types:
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
> [!TIP]
> **Need more?** CCS supports **any Anthropic-compatible API**. Create custom profiles for self-hosted LLMs, enterprise gateways, or alternative providers. See [API Profiles documentation](https://docs.ccs.kaitran.ca/providers/api-profiles).
<br>
## Usage
+23 -58
View File
@@ -1,15 +1,11 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom';
import { lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { ThemeToggle } from '@/components/theme-toggle';
import { GitHubLink } from '@/components/github-link';
import { ConnectionIndicator } from '@/components/connection-indicator';
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client';
import { Skeleton } from '@/components/ui/skeleton';
import { ThemeProvider } from '@/components/theme-provider';
import { Layout } from '@/components/layout';
// Eager load: HomePage (initial route)
import { HomePage } from '@/pages';
@@ -33,58 +29,27 @@ const SettingsPage = lazy(() =>
const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage })));
const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
// Loading fallback for lazy-loaded pages
function PageLoader() {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
function Layout() {
return (
<SidebarProvider>
<AppSidebar />
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
<header className="flex h-12 items-center justify-end px-4 border-b shrink-0">
<div className="flex items-center gap-2">
<ConnectionIndicator />
<GitHubLink />
<ThemeToggle />
</div>
</header>
<div className="flex-1 overflow-auto min-h-0">
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</div>
<LocalhostDisclaimer />
</main>
</SidebarProvider>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/health" element={<HealthPage />} />
<Route path="/shared" element={<SharedPage />} />
</Route>
</Routes>
<Toaster position="top-right" />
</BrowserRouter>
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/health" element={<HealthPage />} />
<Route path="/shared" element={<SharedPage />} />
</Route>
</Routes>
<Toaster position="top-right" />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { CopyIcon, PlayIcon, TerminalIcon } from 'lucide-react';
interface Command {
id: string;
command: string;
description: string;
category: string;
}
const commonCommands: Command[] = [
{
id: '1',
command: 'ccs config',
description: 'Open configuration interface',
category: 'Config',
},
{
id: '2',
command: 'ccs profile create --name my-profile',
description: 'Create a new profile',
category: 'Profile',
},
{
id: '3',
command: 'ccs profile switch --name my-profile',
description: 'Switch to a profile',
category: 'Profile',
},
{
id: '4',
command: 'ccs doctor',
description: 'Check system health',
category: 'Diagnostics',
},
{
id: '5',
command: 'ccs cliproxy list',
description: 'List available CLIProxy providers',
category: 'CLIProxy',
},
{
id: '6',
command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN',
description: 'Add CLIProxy provider',
category: 'CLIProxy',
},
];
export function CommandBuilder() {
const [command, setCommand] = useState('');
const [filteredCommands, setFilteredCommands] = useState(commonCommands);
const handleCommandChange = (value: string) => {
setCommand(value);
const filtered = commonCommands.filter(
(cmd) =>
cmd.command.toLowerCase().includes(value.toLowerCase()) ||
cmd.description.toLowerCase().includes(value.toLowerCase())
);
setFilteredCommands(filtered);
};
const handleCommandSelect = (cmd: string) => {
setCommand(cmd);
setFilteredCommands(commonCommands);
};
const handleCopy = () => {
navigator.clipboard.writeText(command);
};
const handleRun = () => {
console.log('Running command:', command);
};
const categories = Array.from(new Set(commonCommands.map((cmd) => cmd.category)));
return (
<Card className="h-[400px] flex flex-col">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TerminalIcon className="w-5 h-5" />
Command Builder
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col space-y-4">
<div className="space-y-2">
<Input
placeholder="Type or select a command..."
value={command}
onChange={(e) => handleCommandChange(e.target.value)}
className="font-mono"
/>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!command}>
<CopyIcon className="w-4 h-4 mr-1" />
Copy
</Button>
<Button size="sm" onClick={handleRun} disabled={!command}>
<PlayIcon className="w-4 h-4 mr-1" />
Run
</Button>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{categories.map((category) => (
<Badge
key={category}
variant="secondary"
className="text-xs cursor-pointer hover:bg-secondary/80"
onClick={() => {
const categoryCommands = commonCommands.filter((cmd) => cmd.category === category);
console.log(`${category} commands:`, categoryCommands);
}}
>
{category}
</Badge>
))}
</div>
<ScrollArea className="flex-1">
<div className="space-y-2">
{filteredCommands.map((cmd) => (
<div
key={cmd.id}
className="p-3 rounded-lg border cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => handleCommandSelect(cmd.command)}
>
<div className="font-mono text-sm">{cmd.command}</div>
<div className="text-xs text-muted-foreground mt-1">{cmd.description}</div>
<Badge variant="outline" className="mt-2 text-xs">
{cmd.category}
</Badge>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
+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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Suspense } from 'react';
import { Outlet } from 'react-router-dom';
import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { ThemeToggle } from '@/components/theme-toggle';
import { GitHubLink } from '@/components/github-link';
import { ConnectionIndicator } from '@/components/connection-indicator';
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
import { Skeleton } from '@/components/ui/skeleton';
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() {
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="font-semibold text-lg tracking-tight">CCS Config</div>
<div className="flex items-center gap-2">
<ConnectionIndicator />
<GitHubLink />
<ThemeToggle />
</div>
</header>
<div className="flex-1 overflow-auto min-h-0">
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
</div>
<LocalhostDisclaimer />
</main>
</SidebarProvider>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { SettingsIcon, PlayIcon } from 'lucide-react';
interface ProfileCardProps {
profile: {
name: string;
settingsPath: string;
configured: boolean;
isActive?: boolean;
lastUsed?: string;
model?: string;
};
onSwitch?: () => void;
onConfig?: () => void;
onTest?: () => void;
}
export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) {
return (
<Card className={profile.isActive ? 'border-primary' : ''}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{profile.name}</h3>
{profile.isActive && (
<Badge variant="default" className="text-xs">
Active
</Badge>
)}
</div>
<Button variant="ghost" size="sm" onClick={onSwitch} disabled={profile.isActive}>
{profile.isActive ? 'Active' : 'Switch'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{profile.model && (
<div className="text-sm text-muted-foreground">Model: {profile.model}</div>
)}
{profile.lastUsed && (
<div className="text-sm text-muted-foreground">Last used: {profile.lastUsed}</div>
)}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onConfig} className="flex-1">
<SettingsIcon className="w-4 h-4 mr-1" />
Config
</Button>
<Button variant="outline" size="sm" onClick={onTest} className="flex-1">
<PlayIcon className="w-4 h-4 mr-1" />
Test
</Button>
</div>
</CardContent>
</Card>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useProfiles } from '@/hooks/use-profiles';
import { ProfileCard } from './profile-card';
import { Skeleton } from '@/components/ui/skeleton';
export function ProfileDeck() {
const { data: response, isLoading, error } = useProfiles();
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-32 w-full" />
</div>
))}
</div>
);
}
if (error) {
return <div className="text-destructive text-sm">Failed to load profiles: {error.message}</div>;
}
const profiles = response?.profiles || [];
if (!profiles || profiles.length === 0) {
return (
<div className="text-muted-foreground text-center py-8">
No profiles configured. Create your first profile to get started.
</div>
);
}
// Mock data for demonstration
const mockProfiles = profiles.map((profile, index: number) => ({
...profile,
configured: profile.configured ?? false, // Ensure configured is always boolean
isActive: index === 0, // First profile is active
lastUsed: index === 0 ? '2 hours ago' : index === 1 ? '1 day ago' : undefined,
model: index === 0 ? 'claude-3.5-sonnet' : index === 1 ? 'claude-3-haiku' : undefined,
}));
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Profiles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mockProfiles.map((profile) => (
<ProfileCard
key={profile.name}
profile={profile}
onSwitch={() => console.log('Switch to', profile.name)}
onConfig={() => console.log('Config', profile.name)}
onTest={() => console.log('Test', profile.name)}
/>
))}
</div>
</div>
);
}
+61
View File
@@ -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>;
}
+9 -3
View File
@@ -3,11 +3,17 @@ import { Button } from '@/components/ui/button';
import { useTheme } from '@/hooks/use-theme';
export function ThemeToggle() {
const { isDark, toggle } = useTheme();
const { theme, setTheme } = useTheme();
return (
<Button variant="ghost" size="icon" onClick={toggle}>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
<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>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { TrendingUpIcon, TrendingDownIcon, DollarSignIcon, ZapIcon } from 'lucide-react';
interface MetricCardProps {
title: string;
value: string | number;
change?: number;
changeLabel?: string;
icon: React.ReactNode;
trend?: 'up' | 'down' | 'neutral';
}
function MetricCard({ title, value, change, changeLabel, icon, trend }: MetricCardProps) {
const TrendIcon = trend === 'up' ? TrendingUpIcon : trend === 'down' ? TrendingDownIcon : null;
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<h3 className="font-medium text-sm text-muted-foreground">{title}</h3>
</div>
{trend && TrendIcon && (
<Badge variant={trend === 'up' ? 'default' : 'destructive'} className="text-xs">
<TrendIcon className="w-3 h-3 mr-1" />
{change && `${Math.abs(change)}%`}
</Badge>
)}
</div>
<div className="mt-2">
<div className="text-2xl font-bold">{value}</div>
{changeLabel && <div className="text-xs text-muted-foreground mt-1">{changeLabel}</div>}
</div>
</CardContent>
</Card>
);
}
export function ValueMetrics() {
// Mock data for demonstration
const metrics = [
{
title: 'API Cost Saved',
value: '$127.50',
change: 23,
changeLabel: 'vs last month',
icon: <DollarSignIcon className="w-4 h-4 text-green-600" />,
trend: 'up' as const,
},
{
title: 'Tokens Saved',
value: '2.4M',
change: 18,
changeLabel: 'through caching',
icon: <ZapIcon className="w-4 h-4 text-blue-600" />,
trend: 'up' as const,
},
{
title: 'Queries Faster',
value: '43%',
change: 12,
changeLabel: 'average speedup',
icon: <TrendingUpIcon className="w-4 h-4 text-purple-600" />,
trend: 'up' as const,
},
{
title: 'Errors Reduced',
value: '-67%',
change: 67,
changeLabel: 'with retry logic',
icon: <TrendingDownIcon className="w-4 h-4 text-red-600" />,
trend: 'down' as const,
},
];
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Performance Metrics</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{metrics.map((metric, index) => (
<MetricCard key={index} {...metric} />
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Monthly Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<div className="text-lg font-semibold">$342.10</div>
<div className="text-xs text-muted-foreground">Total Saved</div>
</div>
<div>
<div className="text-lg font-semibold">8.7M</div>
<div className="text-xs text-muted-foreground">Tokens Processed</div>
</div>
<div>
<div className="text-lg font-semibold">1,247</div>
<div className="text-xs text-muted-foreground">Queries Handled</div>
</div>
<div>
<div className="text-lg font-semibold">99.8%</div>
<div className="text-xs text-muted-foreground">Uptime</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { createContext } from 'react';
export type Theme = 'dark' | 'light' | 'system';
export type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
};
export const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
isDark: false,
};
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
+7 -21
View File
@@ -1,24 +1,10 @@
import { useEffect, useState } from 'react';
import { useContext } from 'react';
import { ThemeProviderContext } from '@/contexts/theme-context';
function getInitialTheme() {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem('ccs-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return stored === 'dark' || (!stored && prefersDark);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
export function useTheme() {
const [isDark, setIsDark] = useState(getInitialTheme);
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
}, [isDark]);
const toggle = () => {
const newValue = !isDark;
setIsDark(newValue);
localStorage.setItem('ccs-theme', newValue ? 'dark' : 'light');
};
return { isDark, toggle };
}
return context;
};
+50
View File
@@ -149,3 +149,53 @@
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
}
/* Animations */
@keyframes enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes zoom-in {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-in {
animation-duration: 0.5s;
animation-fill-mode: both;
animation-timing-function: ease-out;
}
.fade-in {
animation-name: fade-in;
}
.slide-in-from-bottom-4 {
animation-name: enter;
}
.zoom-in-95 {
animation-name: zoom-in;
}