diff --git a/README.md b/README.md index 0989a7ae..f1693ba8 100644 --- a/README.md +++ b/README.md @@ -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 |
@@ -76,7 +76,7 @@ The dashboard provides visual management for all account types:
-## 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). +
## Usage diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6db0f20a..032a7cf8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ( -
- - -
- ); -} - -function Layout() { - return ( - - -
-
-
- - - -
-
-
- }> - - -
- -
-
- ); -} - export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/ui/src/components/command-builder.tsx b/ui/src/components/command-builder.tsx new file mode 100644 index 00000000..8e33dae3 --- /dev/null +++ b/ui/src/components/command-builder.tsx @@ -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 ( + + + + + Command Builder + + + +
+ handleCommandChange(e.target.value)} + className="font-mono" + /> +
+ + +
+
+ +
+ {categories.map((category) => ( + { + const categoryCommands = commonCommands.filter((cmd) => cmd.category === category); + console.log(`${category} commands:`, categoryCommands); + }} + > + {category} + + ))} +
+ + +
+ {filteredCommands.map((cmd) => ( +
handleCommandSelect(cmd.command)} + > +
{cmd.command}
+
{cmd.description}
+ + {cmd.category} + +
+ ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/hub-footer.tsx b/ui/src/components/hub-footer.tsx new file mode 100644 index 00000000..a81539f8 --- /dev/null +++ b/ui/src/components/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.tsx b/ui/src/components/layout.tsx new file mode 100644 index 00000000..fba044b2 --- /dev/null +++ b/ui/src/components/layout.tsx @@ -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 ( +
+ + +
+ ); +} + +export function Layout() { + return ( + + +
+
+
CCS Config
+
+ + + +
+
+
+ }> + + +
+ +
+
+ ); +} diff --git a/ui/src/components/profile-card.tsx b/ui/src/components/profile-card.tsx new file mode 100644 index 00000000..b760c392 --- /dev/null +++ b/ui/src/components/profile-card.tsx @@ -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 ( + + +
+
+

{profile.name}

+ {profile.isActive && ( + + Active + + )} +
+ +
+
+ + {profile.model && ( +
Model: {profile.model}
+ )} + {profile.lastUsed && ( +
Last used: {profile.lastUsed}
+ )} +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/profile-deck.tsx b/ui/src/components/profile-deck.tsx new file mode 100644 index 00000000..20523469 --- /dev/null +++ b/ui/src/components/profile-deck.tsx @@ -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 ( +
+ {[...Array(6)].map((_, i) => ( +
+ +
+ ))} +
+ ); + } + + if (error) { + return
Failed to load profiles: {error.message}
; + } + + const profiles = response?.profiles || []; + + if (!profiles || profiles.length === 0) { + return ( +
+ No profiles configured. Create your first profile to get started. +
+ ); + } + + // 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 ( +
+

Profiles

+
+ {mockProfiles.map((profile) => ( + console.log('Switch to', profile.name)} + onConfig={() => console.log('Config', profile.name)} + onTest={() => console.log('Test', profile.name)} + /> + ))} +
+
+ ); +} diff --git a/ui/src/components/theme-provider.tsx b/ui/src/components/theme-provider.tsx new file mode 100644 index 00000000..d113fe46 --- /dev/null +++ b/ui/src/components/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/theme-toggle.tsx b/ui/src/components/theme-toggle.tsx index 946aed90..619b3a11 100644 --- a/ui/src/components/theme-toggle.tsx +++ b/ui/src/components/theme-toggle.tsx @@ -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 ( - ); } diff --git a/ui/src/components/value-metrics.tsx b/ui/src/components/value-metrics.tsx new file mode 100644 index 00000000..9975a7b9 --- /dev/null +++ b/ui/src/components/value-metrics.tsx @@ -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 ( + + +
+
+ {icon} +

{title}

+
+ {trend && TrendIcon && ( + + + {change && `${Math.abs(change)}%`} + + )} +
+
+
{value}
+ {changeLabel &&
{changeLabel}
} +
+
+
+ ); +} + +export function ValueMetrics() { + // Mock data for demonstration + const metrics = [ + { + title: 'API Cost Saved', + value: '$127.50', + change: 23, + changeLabel: 'vs last month', + icon: , + trend: 'up' as const, + }, + { + title: 'Tokens Saved', + value: '2.4M', + change: 18, + changeLabel: 'through caching', + icon: , + trend: 'up' as const, + }, + { + title: 'Queries Faster', + value: '43%', + change: 12, + changeLabel: 'average speedup', + icon: , + trend: 'up' as const, + }, + { + title: 'Errors Reduced', + value: '-67%', + change: 67, + changeLabel: 'with retry logic', + icon: , + trend: 'down' as const, + }, + ]; + + return ( +
+

Performance Metrics

+
+ {metrics.map((metric, index) => ( + + ))} +
+ + + + Monthly Summary + + +
+
+
$342.10
+
Total Saved
+
+
+
8.7M
+
Tokens Processed
+
+
+
1,247
+
Queries Handled
+
+
+
99.8%
+
Uptime
+
+
+
+
+
+ ); +} diff --git a/ui/src/contexts/theme-context.ts b/ui/src/contexts/theme-context.ts new file mode 100644 index 00000000..14376f92 --- /dev/null +++ b/ui/src/contexts/theme-context.ts @@ -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(initialState); diff --git a/ui/src/hooks/use-theme.ts b/ui/src/hooks/use-theme.ts index 5d8c6402..0ec62e5f 100644 --- a/ui/src/hooks/use-theme.ts +++ b/ui/src/hooks/use-theme.ts @@ -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; +}; diff --git a/ui/src/index.css b/ui/src/index.css index 1fcc98c7..664ae9c1 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; +}