mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 00:16:46 +00:00
refactor(ui): organize shared components into shared/ directory
- move ccs-logo, code-editor, command-builder, confirm-dialog - move connection-indicator, docs-link, github-link, etc. - add barrel export in shared/index.ts
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CcsLogoProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 24,
|
||||
md: 32,
|
||||
lg: 48,
|
||||
};
|
||||
|
||||
export function CcsLogo({ size = 'md', className, showText = true }: CcsLogoProps) {
|
||||
const dimension = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<img
|
||||
src="/logo/ccs-logo-256.png"
|
||||
alt="CCS Logo"
|
||||
width={dimension}
|
||||
height={dimension}
|
||||
className="rounded"
|
||||
/>
|
||||
{showText && <span className="font-bold text-lg">CCS Config</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ClaudeKit Badge Button
|
||||
*
|
||||
* "Powered by ClaudeKit" badge for navbar, inspired by landing page design.
|
||||
* Compact version optimized for header placement.
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN';
|
||||
|
||||
export function ClaudeKitBadge() {
|
||||
return (
|
||||
<a
|
||||
href={CLAUDEKIT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 px-3 py-1.5 rounded-lg',
|
||||
'bg-accent/10 border-2 border-accent/40',
|
||||
'hover:bg-accent hover:border-accent',
|
||||
'transition-all duration-200 shadow-sm hover:shadow-md'
|
||||
)}
|
||||
title="Powered by ClaudeKit Framework"
|
||||
>
|
||||
<img src="/logos/claudekit-logo.png" alt="ClaudeKit" className="w-5 h-5" />
|
||||
<span className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium uppercase tracking-wide',
|
||||
'text-muted-foreground group-hover:text-accent-foreground/80',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
Powered by
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-bold text-foreground',
|
||||
'group-hover:text-accent-foreground',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
ClaudeKit
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Code Editor Component
|
||||
* Lightweight JSON editor with syntax highlighting, line numbers, and validation
|
||||
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { Highlight, themes } from 'prism-react-renderer';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isSensitiveKey } from '@/lib/sensitive-keys';
|
||||
import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language?: 'json' | 'yaml';
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON and extract error location
|
||||
*/
|
||||
function validateJson(code: string): ValidationResult {
|
||||
if (!code.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(code);
|
||||
return { valid: true };
|
||||
} catch (e) {
|
||||
const error = e as SyntaxError;
|
||||
const message = error.message;
|
||||
|
||||
// Try to extract line number from error message
|
||||
// Format: "... at position X" or "... at line Y column Z"
|
||||
const posMatch = message.match(/position (\d+)/);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
const lines = code.substring(0, pos).split('\n');
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
line: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'json',
|
||||
readonly = false,
|
||||
className,
|
||||
minHeight = '300px',
|
||||
}: CodeEditorProps) {
|
||||
const { isDark } = useTheme();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isMasked, setIsMasked] = useState(true);
|
||||
|
||||
// Validate on every change for JSON
|
||||
const validation = useMemo(() => {
|
||||
if (language === 'json') {
|
||||
return validateJson(value);
|
||||
}
|
||||
return { valid: true };
|
||||
}, [value, language]);
|
||||
|
||||
// Highlight function using prism-react-renderer
|
||||
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
|
||||
const highlightCode = useCallback(
|
||||
(code: string) => (
|
||||
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
|
||||
{({ tokens, getLineProps, getTokenProps }) => {
|
||||
let nextValueIsSensitive = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({ line })}
|
||||
className={cn(validation.line === i + 1 && 'bg-destructive/20')}
|
||||
>
|
||||
{line.map((token, key) => {
|
||||
let isSensitive = false;
|
||||
|
||||
// Check for sensitive keys
|
||||
if (token.types.includes('property')) {
|
||||
const content = token.content.replace(/['"]/g, '');
|
||||
// Use shared sensitive key detection utility
|
||||
if (isSensitiveKey(content)) {
|
||||
nextValueIsSensitive = true;
|
||||
} else {
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
}
|
||||
// Apply masking to values following sensitive keys
|
||||
else if (
|
||||
(token.types.includes('string') ||
|
||||
token.types.includes('number') ||
|
||||
token.types.includes('boolean')) &&
|
||||
nextValueIsSensitive
|
||||
) {
|
||||
isSensitive = true;
|
||||
// Consumes the flag for this value
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
// Reset flag on commas or new keys (handled by property check),
|
||||
// but persist through colons and whitespace
|
||||
else if (token.types.includes('punctuation')) {
|
||||
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
|
||||
nextValueIsSensitive = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenProps = getTokenProps({ token });
|
||||
|
||||
if (isSensitive && isMasked) {
|
||||
tokenProps.className = cn(
|
||||
tokenProps.className,
|
||||
'blur-[3px] select-none opacity-70 transition-all duration-200'
|
||||
);
|
||||
}
|
||||
|
||||
return <span key={key} {...tokenProps} />;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Highlight>
|
||||
),
|
||||
[isDark, language, validation.line, isMasked]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{/* Editor container */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-md border overflow-hidden',
|
||||
'bg-muted/30',
|
||||
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
|
||||
readonly && 'opacity-70 cursor-not-allowed',
|
||||
!validation.valid && 'border-destructive'
|
||||
)}
|
||||
style={{ minHeight }}
|
||||
>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={readonly ? () => {} : onChange}
|
||||
highlight={highlightCode}
|
||||
key={isDark ? 'dark-editor' : 'light-editor'}
|
||||
padding={12}
|
||||
disabled={readonly}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
textareaClassName={cn(
|
||||
'focus:outline-none font-mono text-sm',
|
||||
readonly && 'cursor-not-allowed'
|
||||
)}
|
||||
preClassName="font-mono text-sm"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '0.875rem',
|
||||
minHeight,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Secrets Toggle Overlay */}
|
||||
<div className="absolute top-2 right-2 z-10 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 bg-background/50 hover:bg-background border shadow-sm rounded-full"
|
||||
onClick={() => setIsMasked(!isMasked)}
|
||||
title={isMasked ? 'Reveal sensitive values' : 'Mask sensitive values'}
|
||||
>
|
||||
{isMasked ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation status */}
|
||||
<div className="flex items-center gap-2 mt-2 text-xs">
|
||||
{validation.valid ? (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
Valid {language.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{validation.error}
|
||||
{validation.line && ` (line ${validation.line})`}
|
||||
</span>
|
||||
)}
|
||||
{readonly && <span className="ml-auto text-muted-foreground">(Read-only)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,50 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title,
|
||||
description,
|
||||
confirmText = 'Confirm',
|
||||
variant = 'default',
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700' : ''}
|
||||
>
|
||||
{confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Connection Indicator (Phase 04)
|
||||
*
|
||||
* Shows WebSocket connection status in the header.
|
||||
*/
|
||||
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
import { useWebSocket } from '@/hooks/use-websocket';
|
||||
|
||||
export function ConnectionIndicator() {
|
||||
const { status } = useWebSocket();
|
||||
|
||||
const statusConfig = {
|
||||
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' },
|
||||
connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' },
|
||||
disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-sm ${config.color}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Docs Link Button
|
||||
*
|
||||
* Links to CCS documentation site for guides and reference.
|
||||
*/
|
||||
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DOCS_URL = 'https://docs.ccs.kaitran.ca';
|
||||
|
||||
export function DocsLink() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" asChild title="View documentation">
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* GitHub Link Button
|
||||
*
|
||||
* Links to CCS GitHub issues page for bug reports and feature requests.
|
||||
*/
|
||||
|
||||
import { Github } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues';
|
||||
|
||||
export function GitHubLink() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" asChild title="Report an issue on GitHub">
|
||||
<a href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Github className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Global Environment Variables Indicator
|
||||
*
|
||||
* Shows which env vars from global_env will be injected at runtime.
|
||||
* Displayed below the Raw Configuration (JSON) section in profile editors.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface GlobalEnvConfig {
|
||||
enabled: boolean;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GlobalEnvIndicatorProps {
|
||||
/** Current profile's env vars (to show which are overridden) */
|
||||
profileEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) {
|
||||
const [config, setConfig] = useState<GlobalEnvConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/global-env');
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const data = await res.json();
|
||||
setConfig(data);
|
||||
} catch {
|
||||
setConfig(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if loading or disabled or no vars
|
||||
if (loading) return null;
|
||||
if (!config?.enabled) return null;
|
||||
|
||||
const envVars = config.env || {};
|
||||
const envKeys = Object.keys(envVars);
|
||||
if (envKeys.length === 0) return null;
|
||||
|
||||
// Check which keys are already in profile (won't be overridden)
|
||||
const injectedKeys = envKeys.filter((key) => !(key in profileEnv));
|
||||
const overriddenKeys = envKeys.filter((key) => key in profileEnv);
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
{/* Header - clickable to expand */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<Info className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs text-muted-foreground flex-1 text-left">
|
||||
<span className="font-medium text-foreground">{injectedKeys.length}</span> global env var
|
||||
{injectedKeys.length !== 1 ? 's' : ''} will be injected at runtime
|
||||
{overriddenKeys.length > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400 ml-1">
|
||||
({overriddenKeys.length} overridden by profile)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{/* Injected vars */}
|
||||
{injectedKeys.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{injectedKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs font-mono bg-green-500/10 text-green-700 dark:text-green-400 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-green-500">+</span>
|
||||
<span className="truncate">
|
||||
{key}={envVars[key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overridden vars (profile takes precedence) */}
|
||||
{overriddenKeys.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Skipped (profile already defines):</p>
|
||||
{overriddenKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs font-mono bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1 rounded"
|
||||
>
|
||||
<span className="text-amber-500">~</span>
|
||||
<span className="truncate">{key}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link to settings */}
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1.5 -ml-2">
|
||||
<Link to="/settings?tab=globalenv">
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
Configure in Settings
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared Components Barrel Export
|
||||
*/
|
||||
|
||||
export { CcsLogo } from './ccs-logo';
|
||||
export { ClaudeKitBadge } from './claudekit-badge';
|
||||
export { CodeEditor } from './code-editor';
|
||||
export { CommandBuilder } from './command-builder';
|
||||
export { ConfirmDialog } from './confirm-dialog';
|
||||
export { ConnectionIndicator } from './connection-indicator';
|
||||
export { DocsLink } from './docs-link';
|
||||
export { GitHubLink } from './github-link';
|
||||
export { GlobalEnvIndicator } from './global-env-indicator';
|
||||
export { LocalhostDisclaimer } from './localhost-disclaimer';
|
||||
export { PrivacyToggle } from './privacy-toggle';
|
||||
export { ProjectSelectionDialog } from './project-selection-dialog';
|
||||
export { ProviderIcon } from './provider-icon';
|
||||
export { QuickCommands } from './quick-commands';
|
||||
export { SettingsDialog } from './settings-dialog';
|
||||
export { SponsorButton } from './sponsor-button';
|
||||
export { StatCard } from './stat-card';
|
||||
export { ValueMetrics } from './value-metrics';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Shield, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function LocalhostDisclaimer() {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-yellow-50 dark:bg-yellow-900/20 border-t border-yellow-200 dark:border-yellow-800 px-4 py-2 transition-colors duration-200">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<Shield className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
This dashboard runs locally. All data stays on your machine.
|
||||
</span>
|
||||
<span className="sm:hidden">Local dashboard - data stays on your device.</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 flex-shrink-0 p-1 rounded hover:bg-yellow-100 dark:hover:bg-yellow-800/30 transition-colors"
|
||||
aria-label="Dismiss disclaimer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Privacy Toggle Button
|
||||
* Toggles demo mode to blur personal information (emails, account IDs)
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PrivacyToggle() {
|
||||
const { privacyMode, togglePrivacyMode } = usePrivacy();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={togglePrivacyMode}
|
||||
className={cn(
|
||||
'h-8 w-8 transition-colors',
|
||||
privacyMode && 'text-amber-600 hover:text-amber-700 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
{privacyMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{privacyMode
|
||||
? 'Privacy mode ON - Click to show data'
|
||||
: 'Privacy mode OFF - Click to hide data'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Project Selection Dialog Component
|
||||
*
|
||||
* Displays during OAuth flow when CLIProxyAPI requires user to select
|
||||
* a Google Cloud project. Shows list of available projects with option
|
||||
* to select one or ALL.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface GCloudProject {
|
||||
id: string;
|
||||
name: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface ProjectSelectionDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
provider: string;
|
||||
projects: GCloudProject[];
|
||||
defaultProjectId: string;
|
||||
supportsAll: boolean;
|
||||
onSelect: (selectedId: string) => Promise<void>;
|
||||
/** Timeout in seconds before auto-selecting default */
|
||||
timeoutSeconds?: number;
|
||||
}
|
||||
|
||||
export function ProjectSelectionDialog({
|
||||
open,
|
||||
onClose,
|
||||
sessionId,
|
||||
provider,
|
||||
projects,
|
||||
defaultProjectId,
|
||||
supportsAll,
|
||||
onSelect,
|
||||
timeoutSeconds = 30,
|
||||
}: ProjectSelectionDialogProps) {
|
||||
const [selectedId, setSelectedId] = useState(defaultProjectId);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [countdown, setCountdown] = useState(timeoutSeconds);
|
||||
|
||||
// Countdown timer for auto-selection
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCountdown(timeoutSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
// Auto-submit on timeout
|
||||
handleSubmit(true);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, timeoutSeconds]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedId(defaultProjectId);
|
||||
setIsSubmitting(false);
|
||||
setCountdown(timeoutSeconds);
|
||||
}
|
||||
}, [open, defaultProjectId, timeoutSeconds]);
|
||||
|
||||
const handleSubmit = async (isTimeout = false) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Empty string means use default (press Enter behavior)
|
||||
const submitId = isTimeout ? '' : selectedId;
|
||||
await onSelect(submitId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to submit project selection:', error);
|
||||
// On error, submit empty to use default
|
||||
try {
|
||||
await onSelect('');
|
||||
} catch {
|
||||
// Ignore double-error
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
|
||||
// Suppress unused variable warning - sessionId used for identification
|
||||
void sessionId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && !isSubmitting && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
Select Google Cloud Project
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which project to use for {providerDisplay} authentication.
|
||||
{countdown > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(Auto-selecting default in {countdown}s)
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedId === project.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => !isSubmitting && setSelectedId(project.id)}
|
||||
>
|
||||
{selectedId === project.id ? (
|
||||
<CheckCircle className="w-5 h-5 text-primary" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{project.name}</div>
|
||||
<div className="text-sm text-muted-foreground font-mono">{project.id}</div>
|
||||
</div>
|
||||
{project.id === defaultProjectId && (
|
||||
<span className="text-xs px-2 py-1 bg-secondary rounded">Default</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{supportsAll && (
|
||||
<div
|
||||
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedId === 'ALL'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => !isSubmitting && setSelectedId('ALL')}
|
||||
>
|
||||
{selectedId === 'ALL' ? (
|
||||
<CheckCircle className="w-5 h-5 text-primary" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">All Projects</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Onboard all {projects.length} listed projects
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={() => handleSubmit(true)} disabled={isSubmitting}>
|
||||
Use Default
|
||||
</Button>
|
||||
<Button onClick={() => handleSubmit(false)} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Selecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Confirm Selection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Provider Icon Component
|
||||
* Renders provider logos from /assets/providers/
|
||||
* Supports white background circle variant for dark themes
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
|
||||
interface ProviderIconProps {
|
||||
provider: string;
|
||||
className?: string;
|
||||
size?: number;
|
||||
/** White background circle variant for better visibility */
|
||||
withBackground?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
className,
|
||||
size = 18,
|
||||
withBackground = false,
|
||||
}: ProviderIconProps) {
|
||||
const normalized = provider.toLowerCase();
|
||||
const assetPath = PROVIDER_ASSETS[normalized];
|
||||
|
||||
// Icon size is smaller when inside background circle
|
||||
const iconSize = withBackground ? Math.floor(size * 0.65) : size;
|
||||
|
||||
const iconElement = assetPath ? (
|
||||
<img
|
||||
src={assetPath}
|
||||
alt={`${provider} icon`}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className="shrink-0 object-contain"
|
||||
/>
|
||||
) : (
|
||||
// Fallback: colored text letter
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{
|
||||
color: PROVIDER_COLORS[normalized] || '#6b7280',
|
||||
fontSize: iconSize * 0.6,
|
||||
}}
|
||||
>
|
||||
{provider.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (withBackground) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-full bg-white border border-border flex items-center justify-center shadow-sm',
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{iconElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Without background - original behavior for logos, colored circle for fallback
|
||||
if (assetPath) {
|
||||
return (
|
||||
<img
|
||||
src={assetPath}
|
||||
alt={`${provider} icon`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0 rounded-sm object-contain', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const bgColor = PROVIDER_COLORS[normalized] || '#6b7280';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-full flex items-center justify-center text-white font-bold',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: bgColor,
|
||||
fontSize: size * 0.5,
|
||||
}}
|
||||
>
|
||||
{provider.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Terminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommandSnippet {
|
||||
label: string;
|
||||
command: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const defaultSnippets: CommandSnippet[] = [
|
||||
{
|
||||
label: 'Start Default',
|
||||
command: 'ccs',
|
||||
description: 'Launch Claude with default profile',
|
||||
},
|
||||
{
|
||||
label: 'GLM Profile',
|
||||
command: 'ccs glm',
|
||||
description: 'Switch to GLM model',
|
||||
},
|
||||
{
|
||||
label: 'Health Check',
|
||||
command: 'ccs doctor',
|
||||
description: 'Run system diagnostics',
|
||||
},
|
||||
{
|
||||
label: 'Delegate Task',
|
||||
command: 'ccs glm -p "your task"',
|
||||
description: 'Delegate to GLM profile',
|
||||
},
|
||||
];
|
||||
|
||||
interface QuickCommandsProps {
|
||||
snippets?: CommandSnippet[];
|
||||
}
|
||||
|
||||
export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const copyToClipboard = async (text: string, index: number) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-muted-foreground" />
|
||||
Quick Commands
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{snippets.map((snippet, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'group flex items-center justify-between gap-2 px-3 py-2',
|
||||
'rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{snippet.label}</p>
|
||||
<code className="text-sm font-mono font-medium truncate block">
|
||||
{snippet.command}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => copyToClipboard(snippet.command, index)}
|
||||
title={snippet.description}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Settings Dialog Component
|
||||
* Reusable dialog for editing profile environment variables
|
||||
* Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { MaskedInput } from '@/components/ui/masked-input';
|
||||
import { ConfirmDialog } from './confirm-dialog';
|
||||
import { Save, X, Loader2, Code2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Lazy load CodeEditor to reduce initial bundle size
|
||||
const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor })));
|
||||
|
||||
interface Settings {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SettingsResponse {
|
||||
profile: string;
|
||||
settings: Settings;
|
||||
mtime: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that manages local edits state
|
||||
* Gets unmounted/remounted via key prop when dialog closes/opens
|
||||
*/
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
function SettingsDialogContent({
|
||||
profileName,
|
||||
onClose,
|
||||
}: {
|
||||
profileName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('env');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch settings for selected profile
|
||||
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
|
||||
queryKey: ['settings', profileName],
|
||||
queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()),
|
||||
});
|
||||
|
||||
// Derive raw JSON content: use edits if available, otherwise serialize from data
|
||||
const settings = data?.settings;
|
||||
const rawJsonContent = useMemo(() => {
|
||||
if (rawJsonEdits !== null) {
|
||||
return rawJsonEdits;
|
||||
}
|
||||
if (settings) {
|
||||
return JSON.stringify(settings, null, 2);
|
||||
}
|
||||
return '';
|
||||
}, [rawJsonEdits, settings]);
|
||||
|
||||
// Update raw JSON when user edits
|
||||
const handleRawJsonChange = useCallback((value: string) => {
|
||||
setRawJsonEdits(value);
|
||||
}, []);
|
||||
|
||||
// Derive current settings by merging original data with local edits
|
||||
const currentSettings = useMemo((): Settings | undefined => {
|
||||
const settings = data?.settings;
|
||||
if (!settings) return undefined;
|
||||
return {
|
||||
...settings,
|
||||
env: {
|
||||
...settings.env,
|
||||
...localEdits,
|
||||
},
|
||||
};
|
||||
}, [data?.settings, localEdits]);
|
||||
|
||||
// Check if raw JSON is valid
|
||||
const isRawJsonValid = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(rawJsonContent);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [rawJsonContent]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
let settingsToSave: Settings;
|
||||
|
||||
// Determine what to save based on active tab
|
||||
if (activeTab === 'raw') {
|
||||
// Parse raw JSON content
|
||||
try {
|
||||
settingsToSave = JSON.parse(rawJsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON');
|
||||
}
|
||||
} else {
|
||||
// Use form-based edits
|
||||
settingsToSave = {
|
||||
...data?.settings,
|
||||
env: {
|
||||
...data?.settings?.env,
|
||||
...localEdits,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/settings/${profileName}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
settings: settingsToSave,
|
||||
expectedMtime: data?.mtime,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
throw new Error('CONFLICT');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to save');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
toast.success('Settings saved');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error.message === 'CONFLICT') {
|
||||
setConflictDialog(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
const handleConflictResolve = async (overwrite: boolean) => {
|
||||
setConflictDialog(false);
|
||||
if (overwrite) {
|
||||
// Refetch to get new mtime, then save
|
||||
await refetch();
|
||||
saveMutation.mutate();
|
||||
} else {
|
||||
// Discard local changes and close
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const updateEnvValue = (key: string, value: string) => {
|
||||
setLocalEdits((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const isSensitiveKey = (key: string): boolean => {
|
||||
// Pattern-based matching for sensitive keys (same as backend)
|
||||
const sensitivePatterns = [
|
||||
/^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
|
||||
/_API_KEY$/, // Keys ending with _API_KEY
|
||||
/_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
|
||||
/^API_KEY$/, // Exact match for API_KEY
|
||||
/^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
|
||||
/_SECRET$/, // Keys ending with _SECRET
|
||||
/^SECRET$/, // Exact match for SECRET
|
||||
];
|
||||
return sensitivePatterns.some((pattern) => pattern.test(key));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile: {profileName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure environment variables and settings for this profile.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start border-b rounded-none p-0 h-auto bg-transparent">
|
||||
<TabsTrigger
|
||||
value="env"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
Environment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="raw"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
<Code2 className="w-4 h-4 mr-1" />
|
||||
Raw JSON
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="general"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
General
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="env" className="flex-1 overflow-hidden p-4 pt-4 m-0">
|
||||
<ScrollArea className="h-full pr-4">
|
||||
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(currentSettings.env).map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label className="text-sm font-medium text-foreground">{key}</Label>
|
||||
{isSensitiveKey(key) ? (
|
||||
<MaskedInput
|
||||
value={value}
|
||||
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateEnvValue(key, e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
|
||||
<p>No environment variables configured.</p>
|
||||
<p className="text-xs mt-1">Add variables in your settings.json file.</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw" className="flex-1 overflow-hidden p-4 pt-4 m-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="calc(60vh - 120px)"
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="general" className="p-4 m-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Profile Information</CardTitle>
|
||||
<CardDescription>Details about this configuration file.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">Path</span>
|
||||
<code className="col-span-2 bg-muted p-1 rounded text-xs break-all">
|
||||
{data.path}
|
||||
</code>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="font-medium text-muted-foreground">Last Modified</span>
|
||||
<span className="col-span-2">{new Date(data.mtime).toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="pt-4 mt-auto border-t flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
<X className="w-4 h-4 mr-2" /> Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || (activeTab === 'raw' && !isRawJsonValid)}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" /> Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) {
|
||||
// Handle dialog open/close state changes
|
||||
const handleOpenChange = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
{/* Key prop ensures fresh state on each open */}
|
||||
{open && profileName && (
|
||||
<SettingsDialogContent
|
||||
key={`${profileName}-${open}`}
|
||||
profileName={profileName}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Sponsor Button
|
||||
*
|
||||
* GitHub Sponsors button for navbar.
|
||||
* Heart icon with hover animation.
|
||||
*/
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt';
|
||||
|
||||
export function SponsorButton() {
|
||||
return (
|
||||
<a
|
||||
href={SPONSOR_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'bg-pink-500/10 border-2 border-pink-500/40',
|
||||
'hover:bg-pink-400 hover:border-pink-400',
|
||||
'transition-all duration-200 shadow-sm hover:shadow-md'
|
||||
)}
|
||||
title="Sponsor this project on GitHub"
|
||||
>
|
||||
<Heart
|
||||
className={cn(
|
||||
'w-4 h-4 text-pink-500',
|
||||
'group-hover:text-white group-hover:fill-white',
|
||||
'group-hover:animate-pulse',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-bold text-pink-600 dark:text-pink-300',
|
||||
'group-hover:text-white dark:group-hover:text-white',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
Sponsor
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
color?: string;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'accent';
|
||||
subtitle?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: {
|
||||
iconBg: 'bg-muted',
|
||||
iconColor: 'text-muted-foreground',
|
||||
borderHover: 'hover:border-primary',
|
||||
},
|
||||
success: {
|
||||
iconBg: 'bg-green-500/10',
|
||||
iconColor: 'text-green-600',
|
||||
borderHover: 'hover:border-green-500/50',
|
||||
},
|
||||
warning: {
|
||||
iconBg: 'bg-yellow-500/10',
|
||||
iconColor: 'text-yellow-500',
|
||||
borderHover: 'hover:border-yellow-500/50',
|
||||
},
|
||||
error: {
|
||||
iconBg: 'bg-red-500/10',
|
||||
iconColor: 'text-red-500',
|
||||
borderHover: 'hover:border-red-500/50',
|
||||
},
|
||||
accent: {
|
||||
iconBg: 'bg-accent/10',
|
||||
iconColor: 'text-accent',
|
||||
borderHover: 'hover:border-accent/50',
|
||||
},
|
||||
};
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
variant = 'default',
|
||||
subtitle,
|
||||
onClick,
|
||||
}: StatCardProps) {
|
||||
const styles = variantStyles[variant];
|
||||
const iconColorClass = color || styles.iconColor;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'hover:shadow-lg hover:-translate-y-0.5',
|
||||
styles.borderHover,
|
||||
onClick && 'active:scale-[0.98]'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Icon Container with background */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-12 h-12 rounded-lg transition-transform duration-200',
|
||||
styles.iconBg,
|
||||
'group-hover:scale-105'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-6 h-6', iconColorClass)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate">{title}</p>
|
||||
<p className={cn('text-2xl font-bold font-mono', iconColorClass)}>{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user