mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
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:
13 files changed
+666
-86
No files matched your search
@@ -4,8 +4,8 @@
|
||||
|
||||

|
||||
|
||||
### 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://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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user